Compare commits

..
Author SHA1 Message Date
Shivam Mishra dc60b6b6fa style: fix rubocop module length 2026-01-14 11:33:53 +05:30
Shivam Mishra 2484a4f074 feat: use new helpers 2026-01-14 11:32:01 +05:30
Shivam Mishra c179393a0b feat: add model based usage helper 2026-01-14 11:31:12 +05:30
Shivam Mishra ce822e1e58 test: captain api helper 2026-01-14 11:30:29 +05:30
Shivam Mishra 90e29883fa refactor: move captain config helpers to captain featureable 2026-01-13 20:59:36 +05:30
Shivam Mishra 0b69b13469 feat: always increment one credit usage for audio transcription 2026-01-13 20:44:13 +05:30
Shivam Mishra cca0cf740b feat: enable captain on sidebar 2026-01-13 20:08:09 +05:30
Shivam Mishra b67ba61bda feat: BYOK users consume a single credit only 2026-01-13 20:03:17 +05:30
Shivam Mishra 011bbca28f feat: run migration 2026-01-13 19:59:48 +05:30
Shivam Mishra b493870943 feat: backfill preferences 2026-01-13 19:50:33 +05:30
Shivam Mishra b66cd77c8d feat: always use configured model 2026-01-13 19:36:10 +05:30
Shivam Mishra 1da0a2ff0f feat: fetch correct multiplier based on the feature 2026-01-13 19:35:54 +05:30
Shivam Mishra 8d03354a4c feat: allow increment response to accept credits 2026-01-13 19:35:21 +05:30
Shivam Mishra eee6ccc6f5 feat: add feature key for preference lookup 2026-01-13 19:31:36 +05:30
Shivam Mishra ab1cb7af86 feat: add credit_multiplier_for to fetch the model multiplier 2026-01-13 19:29:59 +05:30
Shivam Mishra 7fba7b946e fix(captain): add operation whitelist to prevent method injection
- Add ALLOWED_OPERATIONS whitelist to validate operation parameter
- Prevent arbitrary method invocation via send()
- Refactor tone methods using metaprogramming to reduce duplication
- Add specs for invalid operations and injection attempts
2026-01-13 18:22:35 +05:30
Shivam MishraandGitHub f63cef20bf Merge branch 'develop' into feat/captain-editor-integration 2026-01-13 18:09:53 +05:30
Shivam MishraandGitHub 61425b6a3b feat: wire up credit usage (#13260) 2026-01-13 18:09:34 +05:30
c483034a07 feat: Add support for sending CSAT surveys via templates (Whatsapp Twilio) (#13143)
Fixes
https://linear.app/chatwoot/issue/CW-6189/support-for-sending-csat-surveys-via-approved-whatsapp

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Vinay Keerthi <11478411+stonecharioteer@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-13 16:32:02 +04:00
Shivam MishraandGitHub 7b51939f07 fix: country_code should be checked against the contact (#13186) 2026-01-13 14:47:27 +05:30
82f5dbe6c1 feat: reply editor follow-up (#13106)
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
2026-01-13 14:29:11 +05:30
821a5b85c2 feat: Add conversations summary CSV export (#13110)
# Pull Request Template

## Description

This PR adds support for exporting conversation summary reports as CSV.
Previously, the Conversations report incorrectly showed an option to
download agent reports; this has now been fixed to export
conversation-level data instead.

Fixes
https://linear.app/chatwoot/issue/CW-6176/conversation-reports-export-button-exports-agent-reports-instead

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Screenshot
<img width="1859" height="1154" alt="image"
src="https://github.com/user-attachments/assets/419d26f4-fda9-4782-aea6-55ffad0c37ab"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] 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
- [ ] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-13 12:30:26 +04:00
Shivam MishraandGitHub 7f057127b0 Merge branch 'develop' into feat/captain-editor-integration 2026-01-13 13:42:30 +05:30
PranavandGitHub 0917e1a646 feat: Add an API to support querying metrics by ChannelType (#13255)
This API gives you how many conversations exist per channel, broken down
by status in a given time period. The max time period is capped to 6
months for now.

**Input Params:**
- **since:** Unix timestamp (seconds) - start of date range
- **until:** Unix timestamp (seconds) - end of date range


**Response Payload:**

```json
{
  "Channel::Sms": {
    "resolved": 85,
    "snoozed": 10,
    "open": 5,
    "pending": 5,
    "total": 100
  },
  "Channel::Email": {
    "resolved": 72,
    "snoozed": 15,
    "open": 13,
    "pending": 13,
    "total": 100
  },
  "Channel::WebWidget": {
    "resolved": 90,
    "snoozed": 7,
    "open": 3,
    "pending": 3,
    "total": 100
  }
}
```

**Definitons:**
resolved = Number of conversations created within the selected time
period that are currently marked as resolved.
snoozed = Number of conversations created within the selected time
period that are currently marked as snoozed.
pending = Number of conversations created within the selected time
period that are currently marked as pending.
open = Number of conversations created within the selected time period
that are currently open.
total = Total number of conversations created within the selected time
period, across all statuses.
2026-01-12 23:18:47 -08:00
Sojan Jose 9407cc2ad5 Merge branch 'hotfix/4.9.2' into develop 2026-01-12 09:15:23 -08:00
Sojan Jose ff68c3a74f Bump version to 4.9.2 2026-01-12 09:14:25 -08:00
Shivam MishraandGitHub 58cec84b93 feat: sanitize html before assiging it to tempDiv (#13252) 2026-01-12 22:41:37 +05:30
Shivam MishraandGitHub c38dfdb739 Merge branch 'develop' into feat/captain-editor-integration 2026-01-12 19:57:00 +05:30
Shivam Mishra ead5b46620 chore: conflict fixes 2026-01-12 15:41:07 +05:30
Shivam MishraandGitHub 772eeafecb Merge branch 'develop' into feat/captain-editor-integration 2026-01-12 14:04:47 +05:30
Sojan Jose 8311657f9c Merge branch 'release/4.9.1'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-12-22 18:56:05 -08:00
Sojan Jose 73140998ff Merge branch 'release/4.9.0'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-12-19 19:12:55 -08:00
8f1cd32fab feat: separate service and controller for editor tasks (#13085)
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2025-12-18 14:23:33 +05:30
Sivin VargheseandGitHub 1a95a99710 feat(CW-6116): Reply Editor AI Changes (#13030)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Fixes https://linear.app/chatwoot/issue/CW-5648/reply-editor-ai-changes
2025-12-17 13:40:57 +05:30
0fe2bf8647 feat: Rewrite APIs for ai editor message improvement (#13059)
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2025-12-15 18:38:21 +05:30
Sojan Jose 4e9f644646 Merge branch 'release/4.8.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-11-18 18:41:51 -08:00
Sojan Jose bfa8a8ed60 Merge branch 'release/4.7.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-10-15 22:11:02 -07:00
Sojan Jose 7ab60d9f9c Merge branch 'release/4.6.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-09-19 13:44:30 +05:30
Sojan Jose faac1486e9 Merge branch 'release/4.5.2'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-08-20 21:45:34 +02:00
Sojan Jose 8340bd615c Merge branch 'release/4.5.1'
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-08-20 16:25:15 +02:00
Sojan Jose 75d3569f22 Merge branch 'release/4.5.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-08-18 18:40:04 +02:00
Sojan Jose 545453537f Merge branch 'release/4.4.0'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-07-16 00:10:42 -07:00
Sojan d75702c6b2 Merge branch 'release/4.3.0'
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-06-17 17:29:27 -07:00
Sojan b76ec878f1 Merge branch 'release/4.2.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-05-20 00:12:06 -07:00
Sojan a954e1eaca Merge branch 'release/4.1.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-04-16 23:10:46 -07:00
Sojan bc5f1722e1 Merge branch 'release/4.0.4'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2025-03-21 18:55:06 -07:00
Sojan 8f39e62570 Merge branch 'release/4.0.3'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-02-27 21:05:35 -08:00
Sojan 7520ca7a99 Merge branch 'release/4.0.2'
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2025-02-21 17:04:31 -08:00
Sojan 35f4e63605 Merge branch 'release/4.0.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2025-01-17 01:13:27 +05:30
Sojan 5773089865 Merge branch 'release/4.0.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2025-01-16 17:11:45 +05:30
Sojan d01c7d3fa7 Merge branch 'release/3.16.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-12-17 23:03:24 +05:30
Sojan 959d2c0d8c Merge branch 'release/3.15.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-11-19 18:02:07 +08:00
Vishnu Narayanan 622f29a0b7 Merge branch 'hotfix/3.14.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2024-10-22 16:03:47 +05:30
131 changed files with 5332 additions and 1170 deletions
+1 -1
View File
@@ -1 +1 @@
4.9.1
4.9.2
@@ -0,0 +1,38 @@
class V2::Reports::ChannelSummaryBuilder
include DateRangeHelper
pattr_initialize [:account!, :params!]
def build
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
end
private
def conversations_by_channel_and_status
account.conversations
.joins(:inbox)
.where(created_at: range)
.group('inboxes.channel_type', 'conversations.status')
.count
.each_with_object({}) do |((channel_type, status), count), grouped|
grouped[channel_type] ||= {}
grouped[channel_type][status] = count
end
end
def build_channel_stats(status_counts)
open_count = status_counts['open'] || 0
resolved_count = status_counts['resolved'] || 0
pending_count = status_counts['pending'] || 0
snoozed_count = status_counts['snoozed'] || 0
{
open: open_count,
resolved: resolved_count,
pending: pending_count,
snoozed: snoozed_count,
total: open_count + resolved_count + pending_count + snoozed_count
}
end
end
@@ -1,38 +1,27 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
before_action :fetch_inbox
before_action :validate_whatsapp_channel
def show
template = @inbox.csat_config&.dig('template')
return render json: { template_exists: false } unless template
service = CsatTemplateManagementService.new(@inbox)
result = service.template_status
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
status_result = @inbox.channel.provider_service.get_template_status(template_name)
render_template_status_response(status_result, template_name)
rescue StandardError => e
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
render json: { error: e.message }, status: :internal_server_error
if result[:service_error]
render json: { error: result[:service_error] }, status: :internal_server_error
else
render json: result
end
end
def create
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
# Delete existing template even though we are using a new one.
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
delete_existing_template_if_needed
result = create_template_via_provider(template_params)
service = CsatTemplateManagementService.new(@inbox)
result = service.create_template(template_params)
render_template_creation_result(result)
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "Error creating CSAT template: #{e.message}"
render json: { error: 'Template creation failed' }, status: :internal_server_error
end
private
@@ -43,9 +32,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
end
def validate_whatsapp_channel
return if @inbox.whatsapp?
return if @inbox.whatsapp? || @inbox.twilio_whatsapp?
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' },
status: :bad_request
end
@@ -57,35 +46,36 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
def create_template_via_provider(template_params)
template_config = {
message: template_params[:message],
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
language: template_params[:language] || DEFAULT_LANGUAGE,
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
}
@inbox.channel.provider_service.create_csat_template(template_config)
end
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
elsif result[:service_error]
render json: { error: result[:service_error] }, status: :internal_server_error
else
render_failed_template_creation(result)
end
end
def render_successful_template_creation(result)
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || DEFAULT_LANGUAGE
}
}, status: :created
if @inbox.twilio_whatsapp?
render json: {
template: {
friendly_name: result[:friendly_name],
content_sid: result[:content_sid],
status: result[:status] || 'pending',
language: result[:language] || 'en'
}
}, status: :created
else
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || 'en'
}
}, status: :created
end
end
def render_failed_template_creation(result)
@@ -98,45 +88,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
}, status: :unprocessable_entity
end
def delete_existing_template_if_needed
template = @inbox.csat_config&.dig('template')
return true if template.blank?
template_name = template['name']
return true if template_name.blank?
template_status = @inbox.channel.provider_service.get_template_status(template_name)
return true unless template_status[:success]
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
if deletion_result[:success]
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
true
else
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
false
end
rescue StandardError => e
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
false
end
def render_template_status_response(status_result, template_name)
if status_result[:success]
render json: {
template_exists: true,
template_name: template_name,
status: status_result[:template][:status],
template_id: status_result[:template][:id]
}
else
render json: {
template_exists: false,
error: 'Template not found'
}
end
end
def parse_whatsapp_error(response_body)
return { user_message: nil, technical_details: nil } if response_body.blank?
@@ -176,7 +176,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :created_at, :language] }] }]
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
end
def permitted_params(channel_attributes = [])
@@ -38,6 +38,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
end
def conversations_summary
@report_data = generate_conversations_report
generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary')
end
def conversation_traffic
@report_data = generate_conversations_heatmap_report
timezone_offset = (params[:timezone_offset] || 0).to_f
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -18,6 +18,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::LabelSummaryBuilder)
end
def channel
return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
render_report_with(V2::Reports::ChannelSummaryBuilder)
end
private
def check_authorization
@@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def permitted_params
params.permit(:since, :until, :business_hours)
end
def date_range_too_long?
return false if permitted_params[:since].blank? || permitted_params[:until].blank?
since_time = Time.zone.at(permitted_params[:since].to_i)
until_time = Time.zone.at(permitted_params[:until].to_i)
(until_time - since_time) > 6.months
end
end
@@ -46,6 +46,13 @@ module Api::V2::Accounts::ReportsHelper
end
end
def generate_conversations_report
builder = V2::Reports::Conversations::MetricBuilder.new(Current.account, build_params(type: :account))
summary = builder.summary
[generate_conversation_report_metrics(summary)]
end
private
def build_params(base_params)
@@ -71,4 +78,16 @@ module Api::V2::Accounts::ReportsHelper
report[:resolved_conversations_count]
]
end
def generate_conversation_report_metrics(summary)
[
summary[:conversations_count],
summary[:incoming_messages_count],
summary[:outgoing_messages_count],
Reports::TimeFormatPresenter.new(summary[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(summary[:avg_resolution_time]).format,
summary[:resolutions_count],
Reports::TimeFormatPresenter.new(summary[:reply_time]).format
]
end
end
@@ -0,0 +1,134 @@
/* global axios */
import ApiClient from '../ApiClient';
/**
* A client for the Captain Tasks API.
* @extends ApiClient
*/
class TasksAPI extends ApiClient {
/**
* Creates a new TasksAPI instance.
*/
constructor() {
super('captain/tasks', { accountScoped: true });
}
/**
* Processes an event using the Captain Tasks API.
* @param {Object} options - The options for the event.
* @param {string} [options.type='improve'] - The type of event to process.
* @param {string} [options.content] - The content of the event.
* @param {string} [options.conversationId] - The ID of the conversation to process the event for.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the result of the event processing.
*/
processEvent({ type = 'improve', content, conversationId }, signal) {
// Route to appropriate endpoint based on type
if (type === 'summarize') {
return this.summarize(conversationId, signal);
}
if (type === 'reply_suggestion') {
return this.replySuggestion(conversationId, signal);
}
if (type === 'label_suggestion') {
return this.labelSuggestion(conversationId, signal);
}
// All other types are rewrite operations
return this.rewrite({ content, operation: type, conversationId }, signal);
}
/**
* Rewrites content with a specific operation.
* @param {Object} options - The rewrite options.
* @param {string} options.content - The content to rewrite.
* @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc).
* @param {string} [options.conversationId] - The conversation ID for context (required for 'improve').
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the rewritten content.
*/
rewrite({ content, operation, conversationId }, signal) {
return axios.post(
`${this.url}/rewrite`,
{
content,
operation,
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Summarizes a conversation.
* @param {string} conversationId - The conversation ID to summarize.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the summary.
*/
summarize(conversationId, signal) {
return axios.post(
`${this.url}/summarize`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Gets a reply suggestion for a conversation.
* @param {string} conversationId - The conversation ID.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the reply suggestion.
*/
replySuggestion(conversationId, signal) {
return axios.post(
`${this.url}/reply_suggestion`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Gets label suggestions for a conversation.
* @param {string} conversationId - The conversation ID.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with label suggestions.
*/
labelSuggestion(conversationId, signal) {
return axios.post(
`${this.url}/label_suggestion`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Sends a follow-up message to continue refining a previous task result.
* @param {Object} options - The follow-up options.
* @param {Object} options.followUpContext - The follow-up context from a previous task.
* @param {string} options.message - The follow-up message/request from the user.
* @param {string} [options.conversationId] - The conversation ID for Langfuse session tracking.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context.
*/
followUp({ followUpContext, message, conversationId }, signal) {
return axios.post(
`${this.url}/follow_up`,
{
follow_up_context: followUpContext,
message,
conversation_display_id: conversationId,
},
{ signal }
);
}
}
export default new TasksAPI();
@@ -1,81 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
/**
* Represents the data object for a OpenAI hook.
* @typedef {Object} ConversationMessageData
* @property {string} [tone] - The tone of the message.
* @property {string} [content] - The content of the message.
* @property {string} [conversation_display_id] - The display ID of the conversation (optional).
*/
/**
* A client for the OpenAI API.
* @extends ApiClient
*/
class OpenAIAPI extends ApiClient {
/**
* Creates a new OpenAIAPI instance.
*/
constructor() {
super('integrations', { accountScoped: true });
/**
* The conversation events supported by the API.
* @type {string[]}
*/
this.conversation_events = [
'summarize',
'reply_suggestion',
'label_suggestion',
];
/**
* The message events supported by the API.
* @type {string[]}
*/
this.message_events = ['rephrase'];
}
/**
* Processes an event using the OpenAI API.
* @param {Object} options - The options for the event.
* @param {string} [options.type='rephrase'] - The type of event to process.
* @param {string} [options.content] - The content of the event.
* @param {string} [options.tone] - The tone of the event.
* @param {string} [options.conversationId] - The ID of the conversation to process the event for.
* @param {string} options.hookId - The ID of the hook to use for processing the event.
* @returns {Promise} A promise that resolves with the result of the event processing.
*/
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
/**
* @type {ConversationMessageData}
*/
let data = {
tone,
content,
};
// Always include conversation_display_id when available for session tracking
if (conversationId) {
data.conversation_display_id = conversationId;
}
// For conversation-level events, only send conversation_display_id
if (this.conversation_events.includes(type)) {
data = {
conversation_display_id: conversationId,
};
}
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
event: {
name: type,
data,
},
});
}
}
export default new OpenAIAPI();
+6
View File
@@ -61,6 +61,12 @@ class ReportsAPI extends ApiClient {
});
}
getConversationsSummaryReports({ from: since, to: until, businessHours }) {
return axios.get(`${this.url}/conversations_summary`, {
params: { since, until, business_hours: businessHours },
});
}
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
@@ -94,6 +94,19 @@
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--violet-1: 253 252 254;
--violet-2: 250 248 255;
--violet-3: 244 240 254;
--violet-4: 235 228 255;
--violet-5: 225 217 255;
--violet-6: 212 202 254;
--violet-7: 194 178 248;
--violet-8: 169 153 236;
--violet-9: 110 86 207;
--violet-10: 100 84 196;
--violet-11: 101 85 183;
--violet-12: 47 38 95;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
@@ -209,6 +222,19 @@
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--violet-1: 20 17 31;
--violet-2: 27 21 37;
--violet-3: 41 31 67;
--violet-4: 50 37 85;
--violet-5: 60 46 105;
--violet-6: 71 56 135;
--violet-7: 86 70 151;
--violet-8: 110 86 171;
--violet-9: 110 86 207;
--violet-10: 125 109 217;
--violet-11: 169 153 236;
--violet-12: 226 221 254;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
@@ -11,7 +11,6 @@ export const CONVERSATION_ATTRIBUTES = {
CAMPAIGN_ID: 'campaign_id',
LABELS: 'labels',
BROWSER_LANGUAGE: 'browser_language',
COUNTRY_CODE: 'country_code',
REFERER: 'referer',
CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at',
@@ -7,7 +7,6 @@ import {
buildAttributesFilterTypes,
CONVERSATION_ATTRIBUTES,
} from './helper/filterHelper';
import countries from 'shared/constants/countries.js';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
/**
@@ -218,17 +217,6 @@ export function useConversationFilterContext() {
filterOperators: equalityOperators.value,
attributeModel: 'additional',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
value: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
inputType: 'searchSelect',
options: countries,
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'additional',
},
{
attributeKey: CONVERSATION_ATTRIBUTES.REFERER,
value: CONVERSATION_ATTRIBUTES.REFERER,
@@ -485,12 +485,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-briefcase',
to: accountScopedRoute('general_settings_index'),
},
// {
// name: 'Settings Captain',
// label: t('SIDEBAR.CAPTAIN_AI'),
// icon: 'i-woot-captain',
// to: accountScopedRoute('captain_settings_index'),
// },
{
name: 'Settings Captain',
label: t('SIDEBAR.CAPTAIN_AI'),
icon: 'i-woot-captain',
to: accountScopedRoute('captain_settings_index'),
},
{
name: 'Settings Agents',
label: t('SIDEBAR.AGENTS'),
@@ -48,9 +48,10 @@ export default {
this.$emit('close');
},
async generateAIContent(type = 'rephrase') {
async generateAIContent(type = 'improve') {
this.isGenerating = true;
this.generatedContent = await this.processEvent(type);
const { message } = await this.processEvent(type);
this.generatedContent = message;
this.isGenerating = false;
},
applyText() {
@@ -46,11 +46,11 @@ const fileName = file => {
</script>
<template>
<div class="flex overflow-auto max-h-[12.5rem]">
<div class="flex flex-wrap gap-y-1 gap-x-2 overflow-auto max-h-[12.5rem]">
<div
v-for="(attachment, index) in nonRecordedAudioAttachments"
:key="attachment.id"
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem] mb-1"
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem]"
>
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
<img
@@ -0,0 +1,246 @@
<script setup>
import { ref, computed, watch, onMounted, useTemplateRef } from 'vue';
import {
buildMessageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
modelValue: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: {
type: String,
default: 'Give copilot additional prompts, or ask anything else...',
},
generatedContent: { type: String, default: '' },
autofocus: {
type: Boolean,
default: true,
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'blur',
'input',
'update:modelValue',
'keyup',
'focus',
'keydown',
'send',
]);
const { formatMessage } = useMessageFormatter();
// Minimal schema with no marks or nodes for copilot input
const copilotSchema = buildMessageSchema([], []);
const createState = (
content,
placeholder,
plugins = [],
enabledMenuOptions = []
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(copilotSchema).parse(content),
plugins: buildEditor({
schema: copilotSchema,
placeholder,
plugins,
enabledMenuOptions,
}),
});
};
// we don't need them to be reactive
// It cases weird issues where the objects are proxied
// and then the editor doesn't work as expected
let editorView = null;
let state = null;
// reactive data
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
// element refs
const editor = useTemplateRef('editor');
function contentFromEditor() {
if (editorView) {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
}
return '';
}
function focusEditorInputField() {
const { tr } = editorView.state;
const selection = Selection.atEnd(tr.doc);
editorView.dispatch(tr.setSelection(selection));
editorView.focus();
}
function emitOnChange() {
emit('update:modelValue', contentFromEditor());
emit('input', contentFromEditor());
}
function onKeyup() {
emit('keyup');
}
function onKeydown() {
emit('keydown');
}
function onBlur() {
emit('blur');
}
function onFocus() {
emit('focus');
}
function checkSelection(editorState) {
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
}
// computed properties
const plugins = computed(() => {
return [];
});
const enabledMenuOptions = computed(() => {
return [];
});
function reloadState() {
state = createState(
props.modelValue,
props.placeholder,
plugins.value,
enabledMenuOptions.value
);
editorView.updateState(state);
focusEditorInputField();
}
function createEditorView() {
editorView = new EditorView(editor.value, {
state: state,
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
if (tx.docChanged) {
emitOnChange();
}
checkSelection(state);
},
handleDOMEvents: {
keyup: onKeyup,
focus: onFocus,
blur: onBlur,
keydown: onKeydown,
},
});
}
function handleSubmit() {
emit('send');
}
// watchers
watch(
computed(() => props.modelValue),
(newValue = '') => {
if (newValue !== contentFromEditor()) {
reloadState();
}
}
);
watch(
computed(() => props.editorId),
() => {
reloadState();
}
);
// lifecycle
onMounted(() => {
state = createState(
props.modelValue,
props.placeholder,
plugins.value,
enabledMenuOptions.value
);
createEditorView();
editorView.updateState(state);
if (props.autofocus) {
focusEditorInputField();
}
});
</script>
<template>
<div class="space-y-2 mb-4">
<div
class="overflow-y-auto"
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
>
<p
v-dompurify-html="formatMessage(generatedContent, false)"
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4 underline decoration-n-iris-8 underline-offset-auto decoration-solid decoration-[10%]"
/>
</div>
<div class="editor-root relative editor--copilot space-x-2">
<div ref="editor" />
<div class="flex items-center justify-end absolute right-2 bottom-2">
<NextButton
class="bg-n-iris-9 text-white !rounded-full"
icon="i-lucide-arrow-up"
solid
sm
@click="handleSubmit"
/>
</div>
</div>
</div>
</template>
<style lang="scss">
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.editor--copilot {
@apply bg-n-iris-5 rounded;
.ProseMirror-woot-style {
min-height: 5rem;
max-height: 7.5rem !important;
overflow: auto;
@apply px-2 !important;
.empty-node {
&::before {
@apply text-n-iris-9 dark:text-n-iris-11;
}
}
}
}
</style>
@@ -0,0 +1,259 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useElementSize, useWindowSize } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import { useAI } from 'dashboard/composables/useAI';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue';
defineProps({
hasSelection: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['executeCopilotAction']);
const { t } = useI18n();
const { draftMessage } = useAI();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
// for now, we don't allow improving just aprt of the selection
// we will add this feature later. Once we do, we can revert the change
const hasSelection = false;
// const hasSelection = props.hasSelection
if (hasSelection) {
items.push({
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY_SELECTION'
),
key: 'improve_selection',
icon: 'i-fluent-pen-sparkle-24-regular',
});
} else if (
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
draftMessage.value
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
key: 'improve',
icon: 'i-fluent-pen-sparkle-24-regular',
});
}
if (draftMessage.value) {
items.push(
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.TITLE'
),
key: 'change_tone',
icon: 'i-fluent-sound-wave-circle-sparkle-24-regular',
subMenuItems: [
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.PROFESSIONAL'
),
key: 'professional',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CASUAL'
),
key: 'casual',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.STRAIGHTFORWARD'
),
key: 'straightforward',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CONFIDENT'
),
key: 'confident',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.FRIENDLY'
),
key: 'friendly',
},
],
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.GRAMMAR'),
key: 'fix_spelling_grammar',
icon: 'i-fluent-flow-sparkle-24-regular',
}
);
}
return items;
});
const generalMenuItems = computed(() => {
const items = [];
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
key: 'reply_suggestion',
icon: 'i-fluent-chat-sparkle-16-regular',
});
}
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize',
icon: 'i-fluent-text-bullet-list-square-sparkle-32-regular',
});
}
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.ASK_COPILOT'),
key: 'ask_copilot',
icon: 'i-fluent-circle-sparkle-24-regular',
});
return items;
});
const menuRef = useTemplateRef('menuRef');
const { height: menuHeight } = useElementSize(menuRef);
const { width: windowWidth } = useWindowSize();
// Smart submenu positioning based on available space
const submenuPosition = computed(() => {
const el = menuRef.value?.$el;
if (!el) return 'ltr:right-full rtl:left-full';
const { left, right } = el.getBoundingClientRect();
const SUBMENU_WIDTH = 200;
const spaceRight = (windowWidth.value ?? window.innerWidth) - right;
const spaceLeft = left;
// Prefer right, fallback to side with more space
const showRight = spaceRight >= SUBMENU_WIDTH || spaceRight >= spaceLeft;
return showRight ? 'left-full' : 'right-full';
});
// Computed style for selection menu positioning (only dynamic top offset)
const selectionMenuStyle = computed(() => {
// Dynamically calculate offset based on actual menu height + 10px gap
const dynamicOffset = menuHeight.value > 0 ? menuHeight.value + 10 : 60;
return {
top: `calc(var(--selection-top) - ${dynamicOffset}px)`,
};
});
const handleMenuItemClick = item => {
// For items with submenus, do nothing on click (hover will show submenu)
if (!item.subMenuItems) {
emit('executeCopilotAction', item.key);
}
};
const handleSubMenuItemClick = (parentItem, subItem) => {
emit('executeCopilotAction', subItem.key);
};
</script>
<template>
<DropdownBody
ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:class="{ 'selection-menu': hasSelection }"
:style="hasSelection ? selectionMenuStyle : {}"
>
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
<div
v-for="item in menuItems"
:key="item.key"
class="w-full relative group/submenu"
>
<Button
:label="item.label"
:icon="item.icon"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
@click="handleMenuItemClick(item)"
>
<template v-if="item.subMenuItems" #default>
<div class="flex items-center gap-1 justify-between w-full">
<span class="min-w-0 truncate">{{ item.label }}</span>
<Icon
icon="i-lucide-chevron-right"
class="text-n-slate-10 size-3"
/>
</div>
</template>
</Button>
<!-- Hover Submenu -->
<DropdownBody
v-if="item.subMenuItems"
class="group-hover/submenu:block hidden [&>ul]:gap-2 [&>ul]:px-3 [&>ul]:py-2.5 [&>ul]:dark:!border-n-strong max-h-[15rem] min-w-32 z-10 top-0"
:class="submenuPosition"
>
<Button
v-for="subItem in item.subMenuItems"
:key="subItem.key + subItem.label"
:label="subItem.label"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start mb-1"
@click="handleSubMenuItemClick(item, subItem)"
/>
</DropdownBody>
</div>
</div>
<div v-if="menuItems.length > 0" class="h-px w-full bg-n-strong" />
<div class="flex flex-col items-start gap-3">
<Button
v-for="(item, index) in generalMenuItems"
:key="index"
:label="item.label"
:icon="item.icon"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
@click="handleMenuItemClick(item)"
/>
</div>
</DropdownBody>
</template>
<style scoped lang="scss">
.selection-menu {
position: absolute !important;
// Default/LTR: position from left
left: var(--selection-left);
// RTL: position from right instead
[dir='rtl'] & {
left: auto;
right: var(--selection-right);
}
}
</style>
@@ -0,0 +1,51 @@
<script setup>
import { computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
import { useKbd } from 'dashboard/composables/utils/useKbd';
defineProps({
isGeneratingContent: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const handleCancel = () => {
emit('cancel');
};
const shortcutKey = useKbd(['$mod', '+', 'enter']);
const acceptLabel = computed(() => {
return `${t('GENERAL.ACCEPT')} (${shortcutKey.value})`;
});
const handleSubmit = () => {
emit('submit');
};
</script>
<template>
<div class="flex justify-between items-center p-3 pt-0">
<NextButton
:label="t('GENERAL.DISCARD')"
slate
link
class="!px-1 hover:!no-underline"
sm
:disabled="isGeneratingContent"
@click="handleCancel"
/>
<NextButton
:label="acceptLabel"
class="bg-n-iris-9 text-white"
solid
sm
:disabled="isGeneratingContent"
@click="handleSubmit"
/>
</div>
</template>
@@ -16,6 +16,7 @@ import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import TagAgents from '../conversation/TagAgents.vue';
import VariableList from '../conversation/VariableList.vue';
import TagTools from '../conversation/TagTools.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
import { useEmitter } from 'dashboard/composables/emitter';
import { useI18n } from 'vue-i18n';
@@ -23,6 +24,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@@ -100,6 +102,7 @@ const emit = defineEmits([
'focus',
'input',
'update:modelValue',
'executeCopilotAction',
]);
const { t } = useI18n();
@@ -107,6 +110,7 @@ const { t } = useI18n();
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium)
@@ -116,7 +120,7 @@ const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
const formatType = props.isPrivate
? DEFAULT_FORMATTING
? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value;
const formatting = getFormattingForEditor(formatType);
return buildMessageSchema(formatting.marks, formatting.nodes);
@@ -124,7 +128,7 @@ const editorSchema = computed(() => {
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
return formatting.menu;
@@ -185,6 +189,21 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor');
const handleCopilotAction = actionKey => {
if (actionKey === 'improve_selection' && editorView?.state) {
const { from, to } = editorView.state.selection;
const selectedText = editorView.state.doc.textBetween(from, to).trim();
if (from !== to && selectedText) {
emit('executeCopilotAction', 'improve', selectedText);
}
} else {
emit('executeCopilotAction', actionKey);
}
showSelectionMenu.value = false;
};
const contentFromEditor = () => {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
};
@@ -367,13 +386,23 @@ function openFileBrowser() {
imageUpload.value.click();
}
function handleCopilotClick() {
showSelectionMenu.value = !showSelectionMenu.value;
}
function handleClickOutside(event) {
// Check if the clicked element or its parents have the ignored class
if (event.target.closest('.ProseMirror-copilot')) return;
showSelectionMenu.value = false;
}
function reloadState(content = props.modelValue) {
const unrefContent = unref(content);
state = createState(
unrefContent,
props.placeholder,
plugins.value,
{ onImageUpload: openFileBrowser },
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -595,7 +624,12 @@ function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
// Use the editor's current schema to ensure compatibility with buildMessageSchema
const currentSchema = editorView.state.schema;
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
// Strip unsupported formatting before parsing to ensure content can be inserted
// into channels that don't support certain markdown features (e.g., API channels)
const sanitizedContent = stripUnsupportedFormatting(content, currentSchema);
let node = new MessageMarkdownTransformer(currentSchema).parse(
sanitizedContent
);
insertNodeIntoEditor(node, from, undefined);
}
@@ -757,7 +791,7 @@ onMounted(() => {
props.modelValue,
props.placeholder,
plugins.value,
{ onImageUpload: openFileBrowser },
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -802,6 +836,14 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
:search-key="toolSearchKey"
@select-tool="content => insertSpecialContent('tool', content)"
/>
<CopilotMenuBar
v-if="showSelectionMenu"
v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected"
:show-selection-menu="showSelectionMenu"
:show-general-menu="false"
@execute-copilot-action="handleCopilotAction"
/>
<input
ref="imageUpload"
type="file"
@@ -855,6 +897,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply size-full;
}
}
.ProseMirror-copilot svg {
@apply fill-n-violet-9;
}
}
}
@@ -994,6 +1040,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0;
}
.ProseMirror-copilot svg {
@apply fill-n-violet-9;
}
}
.ProseMirror-menu-active {
@@ -12,6 +12,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
isReplyRestricted: {
type: Boolean,
default: false,
},
});
defineEmits(['toggleMode']);
@@ -24,11 +28,17 @@ const privateModeSize = useElementSize(wootEditorPrivateMode);
/**
* Computed boolean indicating if the editor is in private note mode
* When disabled, always show NOTE mode regardless of actual mode prop
* When isReplyRestricted is true, force switch to private note
* Otherwise, respect the current mode prop
* @type {ComputedRef<boolean>}
*/
const isPrivate = computed(() => {
return props.disabled || props.mode === REPLY_EDITOR_MODES.NOTE;
if (props.isReplyRestricted) {
// Force switch to private note when replies are restricted
return true;
}
// Otherwise respect the current mode
return props.mode === REPLY_EDITOR_MODES.NOTE;
});
/**
@@ -60,9 +70,9 @@ const translateValue = computed(() => {
<template>
<button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
:disabled="disabled"
:disabled="disabled || isReplyRestricted"
:class="{
'cursor-not-allowed': disabled,
'cursor-not-allowed': disabled || isReplyRestricted,
}"
@click="$emit('toggleMode')"
>
@@ -75,7 +85,7 @@ const translateValue = computed(() => {
<div
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
:class="{
'transition-all duration-300': !disabled,
'transition-all duration-300': !disabled && !isReplyRestricted,
}"
:style="{
'--chip-width': width,
@@ -9,14 +9,13 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
name: 'ReplyBottomPanel',
components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
components: { NextButton, FileUpload, VideoCallButton },
mixins: [inboxMixin],
props: {
isNote: {
@@ -98,6 +97,7 @@ export default {
type: Number,
required: true,
},
// eslint-disable-next-line vue/no-unused-properties
message: {
type: String,
default: '',
@@ -370,13 +370,6 @@ export default {
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
:conversation-id="conversationId"
/>
<AIAssistanceButton
v-if="!isFetchingAppIntegrations"
:conversation-id="conversationId"
:is-private-note="isOnPrivateNote"
:message="message"
@replace-text="replaceText"
/>
<transition name="modal-fade">
<div
v-show="uploadRef && uploadRef.dropActive"
@@ -1,14 +1,21 @@
<script>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { vOnClickOutside } from '@vueuse/components';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EditorModeToggle from './EditorModeToggle.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
export default {
name: 'ReplyTopPanel',
components: {
NextButton,
EditorModeToggle,
CopilotMenuBar,
},
directives: {
OnClickOutside: vOnClickOutside,
},
props: {
mode: {
@@ -19,6 +26,10 @@ export default {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -28,7 +39,7 @@ export default {
default: () => 0,
},
},
emits: ['setReplyMode', 'togglePopout'],
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) {
const setReplyMode = mode => {
emit('setReplyMode', mode);
@@ -47,6 +58,21 @@ export default {
: REPLY_EDITOR_MODES.REPLY;
setReplyMode(newMode);
};
const showCopilotMenu = ref(false);
const handleCopilotAction = actionKey => {
emit('executeCopilotAction', actionKey);
showCopilotMenu.value = false;
};
const toggleCopilotMenu = () => {
showCopilotMenu.value = !showCopilotMenu.value;
};
const handleClickOutside = () => {
showCopilotMenu.value = false;
};
const keyboardEvents = {
'Alt+KeyP': {
action: () => handleNoteClick(),
@@ -64,6 +90,10 @@ export default {
handleReplyClick,
handleNoteClick,
REPLY_EDITOR_MODES,
handleCopilotAction,
showCopilotMenu,
toggleCopilotMenu,
handleClickOutside,
};
},
computed: {
@@ -90,11 +120,13 @@ export default {
</script>
<template>
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
<div
class="flex justify-between gap-2 h-[3.25rem] items-center ltr:pl-3 ltr:pr-2 rtl:pr-3 rtl:pl-2"
>
<EditorModeToggle
:mode="mode"
:disabled="isReplyRestricted"
class="mt-3"
:disabled="disabled"
:is-reply-restricted="isReplyRestricted"
@toggle-mode="handleModeToggle"
/>
<div class="flex items-center mx-4 my-0">
@@ -104,11 +136,34 @@ export default {
</span>
</div>
</div>
<NextButton
ghost
class="ltr:rounded-bl-md rtl:rounded-br-md ltr:rounded-br-none rtl:rounded-bl-none ltr:rounded-tl-none rtl:rounded-tr-none text-n-slate-11 ltr:rounded-tr-[11px] rtl:rounded-tl-[11px]"
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>
<div class="flex items-center gap-2">
<div class="relative">
<NextButton
ghost
:disabled="disabled"
:class="{
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
}"
sm
icon="i-ph-sparkle-fill"
@click="toggleCopilotMenu"
/>
<CopilotMenuBar
v-if="showCopilotMenu"
v-on-click-outside="handleClickOutside"
:has-selection="false"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-copilot-action="handleCopilotAction"
/>
</div>
<NextButton
ghost
class="text-n-slate-11"
sm
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>
</div>
</div>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { ref } from 'vue';
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
defineProps({
showCopilotEditor: {
type: Boolean,
default: false,
},
isGeneratingContent: {
type: Boolean,
default: false,
},
generatedContent: {
type: String,
default: '',
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'focus',
'blur',
'clearSelection',
'contentReady',
'send',
]);
const copilotEditorContent = ref('');
const onFocus = () => {
emit('focus');
};
const onBlur = () => {
emit('blur');
};
const clearEditorSelection = () => {
emit('clearSelection');
};
const onSend = () => {
emit('send', copilotEditorContent.value);
copilotEditorContent.value = '';
};
</script>
<template>
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
@after-enter="emit('contentReady')"
>
<CopilotEditor
v-if="showCopilotEditor && !isGeneratingContent"
key="copilot-editor"
v-model="copilotEditorContent"
class="copilot-editor"
:generated-content="generatedContent"
:min-height="4"
:enabled-menu-options="[]"
:is-popout="isPopout"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@send="onSend"
/>
<div
v-else-if="isGeneratingContent"
key="loading-state"
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
>
<div class="flex items-center gap-2">
<CaptainLoader class="text-n-iris-10 size-4" />
<span class="text-sm text-n-iris-10">
{{ $t('CONVERSATION.REPLYBOX.COPILOT_THINKING') }}
</span>
</div>
</div>
</Transition>
</template>
<style lang="scss">
.copilot-editor {
.ProseMirror-menubar {
display: none;
}
}
</style>
@@ -11,7 +11,7 @@ const openProfileSettings = () => {
<template>
<div
class="my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
class="my-0 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
>
<p class="w-fit !m-0">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
@@ -12,7 +12,9 @@ import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.v
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
import CopilotEditorSection from './CopilotEditorSection.vue';
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
import ReplyBoxBanner from './ReplyBoxBanner.vue';
import QuotedEmailPreview from './QuotedEmailPreview.vue';
@@ -45,6 +47,8 @@ import {
removeSignature,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
@@ -69,6 +73,8 @@ export default {
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
CopilotEditorSection,
CopilotReplyBottomPanel,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -88,6 +94,8 @@ export default {
} = useUISettings();
const replyEditor = useTemplateRef('replyEditor');
const copilot = useCopilotReply();
const shortcutKey = useKbd(['$mod', '+', 'enter']);
return {
uiSettings,
@@ -96,6 +104,8 @@ export default {
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
replyEditor,
copilot,
shortcutKey,
};
},
data() {
@@ -266,7 +276,7 @@ export default {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
}
const keyLabel = this.isEditorHotKeyEnabled('cmd_enter')
? '(⌘ + ↵)'
? `(${this.shortcutKey})`
: '(↵)';
return `${sendMessageText} ${keyLabel}`;
},
@@ -399,6 +409,9 @@ export default {
!!this.quotedEmailText
);
},
isDefaultEditorMode() {
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -408,6 +421,8 @@ export default {
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
// like self-assign or other updates that do not actually change the conversation context
this.setCCAndToEmailsFromLastChat();
// Reset Copilot editor state (includes cancelling ongoing generation)
this.copilot.reset();
}
if (this.isOnPrivateNote) {
@@ -612,7 +627,9 @@ export default {
},
'$mod+Enter': {
action: () => {
if (this.isAValidEvent('cmd_enter')) {
if (this.copilot.isActive.value && this.isFocused) {
this.onSubmitCopilotReply();
} else if (this.isAValidEvent('cmd_enter')) {
this.onSendReply();
}
},
@@ -810,6 +827,9 @@ export default {
this.updateEditorSelectionWith = content;
this.onFocus();
},
executeCopilotAction(action, data) {
this.copilot.execute(action, data);
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
@@ -1075,6 +1095,9 @@ export default {
togglePopout() {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
},
onSubmitCopilotReply() {
this.message = this.copilot.accept();
},
},
};
</script>
@@ -1085,11 +1108,17 @@ export default {
<ReplyTopPanel
:mode="replyType"
:is-reply-restricted="isReplyRestricted"
:disabled="
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
showAudioRecorderEditor
"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode"
@toggle-popout="togglePopout"
@toggle-copilot="copilot.toggleEditor"
@execute-copilot-action="executeCopilotAction"
/>
<ArticleSearchPopover
v-if="showArticleSearchPopover && connectedPortalSlug"
@@ -1097,112 +1126,167 @@ export default {
@insert="handleInsert"
@close="onSearchPopoverClose"
/>
<div class="reply-box__top">
<ReplyToMessage
v-if="shouldShowReplyToMessage"
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
}"
:on-click="addIntoEditor"
/>
<ReplyEmailHead
v-if="showReplyHead"
v-model:cc-emails="ccEmails"
v-model:bcc-emails="bccEmails"
v-model:to-emails="toEmails"
/>
<AudioRecorder
v-if="showAudioRecorderEditor"
ref="audioRecorderInput"
:audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder"
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<WootMessageEditor
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
/>
<QuotedEmailPreview
v-if="shouldShowQuotedPreview"
:quoted-email-text="quotedEmailText"
:preview-text="quotedEmailPreviewText"
@toggle="toggleQuotedReply"
/>
</div>
<div
v-if="hasAttachments && !showAudioRecorderEditor"
class="attachment-preview-box"
@paste="onPaste"
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
>
<AttachmentPreview
class="flex-col mt-4"
:attachments="attachedFiles"
@remove-attachment="removeAttachment"
<div :key="copilot.editorTransitionKey.value" class="reply-box__top">
<ReplyToMessage
v-if="shouldShowReplyToMessage"
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
}"
:on-click="addIntoEditor"
/>
<ReplyEmailHead
v-if="showReplyHead && isDefaultEditorMode"
v-model:cc-emails="ccEmails"
v-model:bcc-emails="bccEmails"
v-model:to-emails="toEmails"
/>
<AudioRecorder
v-if="showAudioRecorderEditor"
ref="audioRecorderInput"
:audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder"
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<CopilotEditorSection
v-if="copilot.isActive.value && !showAudioRecorderEditor"
:show-copilot-editor="copilot.showEditor.value"
:is-generating-content="copilot.isGenerating.value"
:generated-content="copilot.generatedContent.value"
:is-popout="popOutReplyBox"
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@close="copilot.showEditor.value = false"
@content-ready="copilot.setContentReady"
@send="copilot.sendFollowUp"
/>
<WootMessageEditor
v-else-if="!showAudioRecorderEditor"
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
@execute-copilot-action="executeCopilotAction"
/>
<QuotedEmailPreview
v-if="shouldShowQuotedPreview && isDefaultEditorMode"
:quoted-email-text="quotedEmailText"
:preview-text="quotedEmailPreviewText"
class="mb-2"
@toggle="toggleQuotedReply"
/>
<div
v-if="hasAttachments && isDefaultEditorMode"
class="bg-transparent py-0 mb-2"
@paste="onPaste"
>
<AttachmentPreview
class="mt-2"
:attachments="attachedFiles"
@remove-attachment="removeAttachment"
/>
</div>
<MessageSignatureMissingAlert
v-if="
isSignatureEnabledForInbox &&
!isSignatureAvailable &&
isDefaultEditorMode
"
class="mb-2"
/>
</div>
</Transition>
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
>
<CopilotReplyBottomPanel
v-if="copilot.isActive.value"
key="copilot-bottom-panel"
:is-generating-content="copilot.isButtonDisabled.value"
@submit="onSubmitCopilotReply"
@cancel="copilot.toggleEditor"
/>
</div>
<MessageSignatureMissingAlert
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
/>
<ReplyBottomPanel
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:enable-whats-app-templates="showWhatsappTemplates"
:enable-content-templates="showContentTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
:quoted-reply-enabled="quotedReplyPreference"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:message="message"
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
<ReplyBottomPanel
v-else
key="reply-bottom-panel"
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:enable-whats-app-templates="showWhatsappTemplates"
:enable-content-templates="showContentTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
:quoted-reply-enabled="quotedReplyPreference"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:message="message"
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
</Transition>
<WhatsappTemplates
:inbox-id="inbox.id"
:show="showWhatsAppTemplatesModal"
@@ -1232,13 +1316,7 @@ export default {
@apply mb-0;
}
.attachment-preview-box {
@apply bg-transparent py-0 px-4;
}
.reply-box {
transition: height 2s cubic-bezier(0.37, 0, 0.63, 1);
@apply relative mb-2 mx-2 border border-n-weak rounded-xl bg-n-solid-1;
&.is-private {
@@ -78,14 +78,6 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{
attributeKey: 'country_code',
attributeI18nKey: 'COUNTRY_NAME',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{
attributeKey: 'referer',
attributeI18nKey: 'REFERER_LINK',
@@ -171,10 +163,6 @@ export const filterAttributeGroups = [
key: 'browser_language',
i18nKey: 'BROWSER_LANGUAGE',
},
{
key: 'country_code',
i18nKey: 'COUNTRY_NAME',
},
{
key: 'referer',
i18nKey: 'REFERER_LINK',
@@ -0,0 +1,33 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
</script>
<template>
<Icon v-once icon="i-woot-captain" class="jumping-logo" />
</template>
<style scoped>
.jumping-logo {
transform-origin: center bottom;
animation: jump 1s cubic-bezier(0.28, 0.84, 0.42, 1) infinite;
will-change: transform;
}
@keyframes jump {
0% {
transform: translateY(0) scale(1, 1);
}
20% {
transform: translateY(0) scale(1.05, 0.95);
}
50% {
transform: translateY(-5px) scale(0.95, 1.05);
}
80% {
transform: translateY(0) scale(1.02, 0.98);
}
100% {
transform: translateY(0) scale(1, 1);
}
}
</style>
@@ -102,8 +102,8 @@ const createNonDraftMessageAIAssistActions = (t, replyMode) => {
const createDraftMessageAIAssistActions = t => {
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
key: 'rephrase',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CONFIDENT'),
key: 'confident',
icon: ICON_AI_ASSIST,
},
{
@@ -112,28 +112,23 @@ const createDraftMessageAIAssistActions = t => {
icon: ICON_AI_GRAMMAR,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
key: 'expand',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.PROFESSIONAL'),
key: 'professional',
icon: ICON_AI_EXPAND,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
key: 'shorten',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CASUAL'),
key: 'casual',
icon: ICON_AI_SHORTEN,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
key: 'make_friendly',
key: 'friendly',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FORMAL'),
key: 'make_formal',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
key: 'simplify',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.STRAIGHTFORWARD'),
key: 'straightforward',
icon: ICON_AI_ASSIST,
},
];
@@ -5,12 +5,12 @@ import {
useMapGetter,
} from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import OpenAPI from 'dashboard/api/integrations/openapi';
import TasksAPI from 'dashboard/api/captain/tasks';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
vi.mock('dashboard/composables/store');
vi.mock('vue-i18n');
vi.mock('dashboard/api/integrations/openapi');
vi.mock('dashboard/api/captain/tasks');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
@@ -94,7 +94,7 @@ describe('useAI', () => {
});
it('fetches label suggestions', async () => {
OpenAPI.processEvent.mockResolvedValue({
TasksAPI.processEvent.mockResolvedValue({
data: { message: 'label1, label2' },
});
@@ -111,9 +111,8 @@ describe('useAI', () => {
const { fetchLabelSuggestions } = useAI();
const result = await fetchLabelSuggestions();
expect(OpenAPI.processEvent).toHaveBeenCalledWith({
expect(TasksAPI.processEvent).toHaveBeenCalledWith({
type: 'label_suggestion',
hookId: 'hook1',
conversationId: '123',
});
+62 -28
View File
@@ -7,7 +7,7 @@ import {
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import OpenAPI from 'dashboard/api/integrations/openapi';
import TasksAPI from 'dashboard/api/captain/tasks';
/**
* Cleans and normalizes a list of labels.
@@ -57,7 +57,7 @@ export function useAI() {
* Computed property to check if AI integration is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isAIIntegrationEnabled = computed(() => !!aiIntegration.value);
const isAIIntegrationEnabled = computed(() => true);
/**
* Computed property to check if label suggestion feature is enabled.
@@ -77,12 +77,6 @@ export function useAI() {
*/
const isFetchingAppIntegrations = computed(() => uiFlags.value.isFetching);
/**
* Computed property for the hook ID.
* @type {import('vue').ComputedRef<string|undefined>}
*/
const hookId = computed(() => aiIntegration.value?.id);
/**
* Computed property for the conversation ID.
* @type {import('vue').ComputedRef<string|undefined>}
@@ -115,6 +109,21 @@ export function useAI() {
}
};
/**
* Handles API errors and displays appropriate error messages.
* Silently returns for aborted requests.
* @param {Error} error - The error object from the API call.
*/
const handleAPIError = error => {
if (error.name === 'AbortError' || error.name === 'CanceledError') {
return;
}
const errorMessage =
error.response?.data?.error ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
};
/**
* Records analytics for AI-related events.
* @param {string} type - The type of event.
@@ -139,9 +148,8 @@ export function useAI() {
if (!conversationId.value) return [];
try {
const result = await OpenAPI.processEvent({
const result = await TasksAPI.processEvent({
type: 'label_suggestion',
hookId: hookId.value,
conversationId: conversationId.value,
});
@@ -156,29 +164,54 @@ export function useAI() {
};
/**
* Processes an AI event, such as rephrasing content.
* @param {string} [type='rephrase'] - The type of AI event to process.
* @returns {Promise<string>} The generated message or an empty string if an error occurs.
* Processes an AI event, such as improving content.
* @param {string} [type='improve'] - The type of AI event to process.
* @param {string} [content=''] - The content to process (for full message) or selected text (for selection-based).
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The generated message and optional follow-up context.
*/
const processEvent = async (type = 'rephrase') => {
const processEvent = async (type = 'improve', content = '', options = {}) => {
try {
const result = await OpenAPI.processEvent({
hookId: hookId.value,
type,
content: draftMessage.value,
conversationId: conversationId.value,
});
const result = await TasksAPI.processEvent(
{
type,
content: content || draftMessage.value,
conversationId: conversationId.value,
},
options.signal
);
const {
data: { message: generatedMessage },
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return generatedMessage;
return { message: generatedMessage, followUpContext };
} catch (error) {
const errorData = error.response.data.error;
const errorMessage =
errorData?.error?.message ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
return '';
handleAPIError(error);
return { message: '' };
}
};
/**
* Sends a follow-up message to refine a previous AI task result.
* @param {Object} options - The follow-up options.
* @param {Object} options.followUpContext - The follow-up context from a previous task.
* @param {string} options.message - The follow-up message/request from the user.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext: Object}>} The follow-up response and updated context.
*/
const followUp = async ({ followUpContext, message, signal }) => {
try {
const result = await TasksAPI.followUp(
{ followUpContext, message, conversationId: conversationId.value },
signal
);
const {
data: { message: generatedMessage, follow_up_context: updatedContext },
} = result;
return { message: generatedMessage, followUpContext: updatedContext };
} catch (error) {
handleAPIError(error);
return { message: '', followUpContext };
}
};
@@ -199,5 +232,6 @@ export function useAI() {
recordAnalytics,
fetchLabelSuggestions,
processEvent,
followUp,
};
}
@@ -0,0 +1,162 @@
import { ref, computed } from 'vue';
import { useAI } from 'dashboard/composables/useAI';
import { useUISettings } from 'dashboard/composables/useUISettings';
/**
* Composable for managing Copilot reply generation state and actions.
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
*
* @returns {Object} Copilot reply state and methods
*/
export function useCopilotReply() {
const { processEvent, followUp } = useAI();
const { updateUISettings } = useUISettings();
const showEditor = ref(false);
const isGenerating = ref(false);
const isContentReady = ref(false);
const generatedContent = ref('');
const followUpContext = ref(null);
const abortController = ref(null);
const isActive = computed(() => showEditor.value || isGenerating.value);
const isButtonDisabled = computed(
() => isGenerating.value || !isContentReady.value
);
const editorTransitionKey = computed(() =>
isActive.value ? 'copilot' : 'rich'
);
/**
* Resets all copilot editor state and cancels any ongoing generation.
*/
function reset() {
if (abortController.value) {
abortController.value.abort();
abortController.value = null;
}
showEditor.value = false;
isGenerating.value = false;
isContentReady.value = false;
generatedContent.value = '';
followUpContext.value = null;
}
/**
* Toggles the copilot editor visibility.
*/
function toggleEditor() {
showEditor.value = !showEditor.value;
}
/**
* Marks content as ready (called after transition completes).
*/
function setContentReady() {
isContentReady.value = true;
}
/**
* Executes a copilot action (e.g., improve, fix grammar).
* @param {string} action - The action type
* @param {string} data - The content to process
*/
async function execute(action, data) {
if (action === 'ask_copilot') {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
});
return;
}
// Reset and start new generation
reset();
abortController.value = new AbortController();
isGenerating.value = true;
isContentReady.value = false;
try {
const { message: content, followUpContext: newContext } =
await processEvent(action, data, {
signal: abortController.value.signal,
});
if (!abortController.value?.signal.aborted) {
generatedContent.value = content;
followUpContext.value = newContext;
if (content) showEditor.value = true;
isGenerating.value = false;
}
} catch {
if (!abortController.value?.signal.aborted) {
isGenerating.value = false;
}
}
}
/**
* Sends a follow-up message to refine the current generated content.
* @param {string} message - The follow-up message from the user
*/
async function sendFollowUp(message) {
if (!followUpContext.value || !message.trim()) return;
abortController.value = new AbortController();
isGenerating.value = true;
isContentReady.value = false;
try {
const { message: content, followUpContext: updatedContext } =
await followUp({
followUpContext: followUpContext.value,
message,
signal: abortController.value.signal,
});
if (!abortController.value?.signal.aborted) {
if (content) {
generatedContent.value = content;
followUpContext.value = updatedContext;
showEditor.value = true;
}
isGenerating.value = false;
}
} catch {
if (!abortController.value?.signal.aborted) {
isGenerating.value = false;
}
}
}
/**
* Accepts the generated content and returns it.
* Note: Formatting is automatically stripped by the Editor component's
* createState function based on the channel's schema.
* @returns {string} The content ready for the editor
*/
function accept() {
const content = generatedContent.value;
showEditor.value = false;
return content;
}
return {
showEditor,
isGenerating,
isContentReady,
generatedContent,
followUpContext,
isActive,
isButtonDisabled,
editorTransitionKey,
reset,
toggleEditor,
setContentReady,
execute,
sendFollowUp,
accept,
};
}
@@ -1,14 +1,25 @@
import { computed } from 'vue';
function isMacOS() {
// Check modern userAgentData API first
if (navigator.userAgentData?.platform) {
return navigator.userAgentData.platform === 'macOS';
}
// Fallback to navigator.platform
return (
navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone'
);
}
export function useKbd(keys) {
const keySymbols = {
$mod: navigator.platform.includes('Mac') ? '⌘' : 'Ctrl',
$mod: isMacOS() ? '⌘' : 'Ctrl',
shift: '⇧',
alt: '⌥',
ctrl: 'Ctrl',
cmd: '⌘',
option: '⌥',
enter: '',
enter: '',
tab: '⇥',
esc: '⎋',
};
@@ -16,7 +27,11 @@ export function useKbd(keys) {
return computed(() => {
return keys
.map(key => keySymbols[key.toLowerCase()] || key)
.join('')
.join(' ')
.toUpperCase();
});
}
export function getModifierKey() {
return isMacOS() ? '⌘' : 'Ctrl';
}
+24 -3
View File
@@ -7,6 +7,7 @@ export const FORMATTING = {
marks: ['strong', 'em', 'code', 'link'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -21,6 +22,7 @@ export const FORMATTING = {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -35,12 +37,13 @@ export const FORMATTING = {
'Channel::Api': {
marks: ['strong', 'em'],
nodes: [],
menu: ['strong', 'em', 'undo', 'redo'],
menu: ['copilot', 'strong', 'em', 'undo', 'redo'],
},
'Channel::FacebookPage': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -70,6 +73,7 @@ export const FORMATTING = {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -83,17 +87,18 @@ export const FORMATTING = {
'Channel::Line': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['codeBlock'],
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
menu: ['copilot', 'strong', 'em', 'code', 'strike', 'undo', 'redo'],
},
'Channel::Telegram': {
marks: ['strong', 'em', 'link', 'code'],
nodes: [],
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
menu: ['copilot', 'strong', 'em', 'link', 'code', 'undo', 'redo'],
},
'Channel::Instagram': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -115,6 +120,22 @@ export const FORMATTING = {
menu: [],
},
// Special contexts (not actual channels)
'Context::PrivateNote': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'copilot',
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Context::Default': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
@@ -88,6 +88,7 @@ export const OPEN_AI_EVENTS = Object.freeze({
SUMMARIZE: 'OpenAI: Used summarize',
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
REPHRASE: 'OpenAI: Used rephrase',
IMPROVE: 'OpenAI: Used improve',
FIX_SPELLING_AND_GRAMMAR: 'OpenAI: Used fix spelling and grammar',
SHORTEN: 'OpenAI: Used shorten',
EXPAND: 'OpenAI: Used expand',
@@ -1,3 +1,5 @@
import DOMPurify from 'dompurify';
// Quote detection strategies
const QUOTE_INDICATORS = [
'.gmail_quote_container',
@@ -29,7 +31,7 @@ export class EmailQuoteExtractor {
static extractQuotes(htmlContent) {
// Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Remove elements matching class selectors
QUOTE_INDICATORS.forEach(selector => {
@@ -56,7 +58,7 @@ export class EmailQuoteExtractor {
*/
static hasQuotes(htmlContent) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent;
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Check for class-based quotes
// eslint-disable-next-line no-restricted-syntax
@@ -1,4 +1,5 @@
import { format, parseISO, isValid as isValidDate } from 'date-fns';
import DOMPurify from 'dompurify';
/**
* Extracts plain text from HTML content
@@ -13,7 +14,7 @@ export const extractPlainTextFromHtml = html => {
return html.replace(/<[^>]*>/g, ' ');
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
tempDiv.innerHTML = DOMPurify.sanitize(html);
return tempDiv.textContent || tempDiv.innerText || '';
};
@@ -96,4 +96,58 @@ describe('EmailQuoteExtractor', () => {
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
});
describe('HTML sanitization', () => {
it('removes onerror handlers from img tags in extractQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).toContain('<p>Hello</p>');
});
it('removes onerror handlers from img tags in hasQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
// Should not throw and should safely check for quotes
const result = EmailQuoteExtractor.hasQuotes(maliciousHtml);
expect(result).toBe(false);
});
it('removes script tags in extractQuotes', () => {
const maliciousHtml =
'<p>Content</p><script>alert("xss")</script><p>More</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('<script');
expect(cleanedHtml).not.toContain('alert');
expect(cleanedHtml).toContain('<p>Content</p>');
expect(cleanedHtml).toContain('<p>More</p>');
});
it('removes onclick handlers in extractQuotes', () => {
const maliciousHtml = '<p onclick="alert(1)">Click me</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onclick');
expect(cleanedHtml).toContain('Click me');
});
it('removes javascript: URLs in extractQuotes', () => {
const maliciousHtml = '<a href="javascript:alert(1)">Link</a>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
// eslint-disable-next-line no-script-url
expect(cleanedHtml).not.toContain('javascript:');
expect(cleanedHtml).toContain('Link');
});
it('removes encoded payloads with event handlers in extractQuotes', () => {
const maliciousHtml =
'<img src="x" id="PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" onerror="eval(atob(this.id))">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).not.toContain('eval');
});
});
});
@@ -33,6 +33,26 @@ describe('quotedEmailHelper', () => {
expect(result).toContain('Line 1');
expect(result).toContain('Line 2');
});
it('sanitizes onerror handlers from img tags', () => {
const html = '<p>Hello</p><img src="x" onerror="alert(1)">';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Hello');
});
it('sanitizes script tags', () => {
const html = '<p>Safe</p><script>alert(1)</script><p>Content</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toContain('Safe');
expect(result).toContain('Content');
expect(result).not.toContain('alert');
});
it('sanitizes onclick handlers', () => {
const html = '<p onclick="alert(1)">Click me</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Click me');
});
});
describe('getEmailSenderName', () => {
@@ -186,6 +186,7 @@
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"CLICK_HERE": "Click here to update",
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
},
@@ -205,7 +206,7 @@
"DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"": "",
"COPILOT_THINKING": "Copilot is thinking",
"EMAIL_HEAD": {
"TO": "TO",
"ADD_BCC": "Add bcc",
@@ -8,6 +8,8 @@
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
"ACCEPT": "Accept",
"DISCARD": "Discard",
"PREFERRED": "Preferred"
}
}
@@ -145,7 +145,29 @@
"EXPAND": "Expand",
"MAKE_FRIENDLY": "Change message tone to friendly",
"MAKE_FORMAL": "Use formal tone",
"SIMPLIFY": "Simplify"
"SIMPLIFY": "Simplify",
"CONFIDENT": "Use confident tone",
"PROFESSIONAL": "Use professional tone",
"CASUAL": "Use casual tone",
"STRAIGHTFORWARD": "Use straightforward tone"
},
"REPLY_OPTIONS": {
"IMPROVE_REPLY": "Improve reply",
"IMPROVE_REPLY_SELECTION": "Improve the selection",
"CHANGE_TONE": {
"TITLE": "Change tone",
"OPTIONS": {
"PROFESSIONAL": "Professional",
"CASUAL": "Casual",
"STRAIGHTFORWARD": "Straightforward",
"CONFIDENT": "Confident",
"FRIENDLY": "Friendly"
}
},
"GRAMMAR": "Fix grammar & spelling",
"SUGGESTION": "Suggest a reply",
"SUMMARIZE": "Summarize the conversation",
"ASK_COPILOT": "Ask Copilot"
},
"ASSISTANCE_MODAL": {
"DRAFT_TITLE": "Draft content",
@@ -3,7 +3,7 @@
"HEADER": "Conversations",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
"DOWNLOAD_CONVERSATION_REPORTS": "Download conversation reports",
"DATA_FETCHING_FAILED": "Failed to fetch data, please try again later.",
"SUMMARY_FETCHING_FAILED": "Failed to fetch summary, please try again later.",
"METRICS": {
@@ -28,10 +28,15 @@ const { t } = useI18n();
const store = useStore();
const labels = useMapGetter('labels/getLabels');
const { isAWhatsAppCloudChannel: isWhatsAppChannel } = useInbox(
const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox(
props.inbox?.id
);
// Computed to check if it's any type of WhatsApp channel (Cloud or Twilio)
const isAnyWhatsAppChannel = computed(
() => isAWhatsAppChannel.value || isATwilioWhatsAppChannel.value
);
const isUpdating = ref(false);
const selectedLabelValues = ref([]);
const currentLabel = ref('');
@@ -116,7 +121,9 @@ const templateApprovalStatus = computed(() => {
// Handle existing template with status
if (templateStatus.value?.template_exists && templateStatus.value.status) {
return statusMap[templateStatus.value.status] || statusMap.PENDING;
// Convert status to uppercase for consistency with statusMap keys
const normalizedStatus = templateStatus.value.status.toUpperCase();
return statusMap[normalizedStatus] || statusMap.PENDING;
}
// Default case - no template exists
@@ -155,7 +162,7 @@ const initializeState = () => {
: [];
// Store original template values for change detection
if (isWhatsAppChannel.value) {
if (isAnyWhatsAppChannel.value) {
originalTemplateValues.value = {
message: state.message,
templateButtonText: state.templateButtonText,
@@ -165,7 +172,7 @@ const initializeState = () => {
};
const checkTemplateStatus = async () => {
if (!isWhatsAppChannel.value) return;
if (!isAnyWhatsAppChannel.value) return;
try {
templateLoading.value = true;
@@ -195,7 +202,7 @@ const checkTemplateStatus = async () => {
onMounted(() => {
initializeState();
if (!labels.value?.length) store.dispatch('labels/get');
if (isWhatsAppChannel.value) checkTemplateStatus();
if (isAnyWhatsAppChannel.value) checkTemplateStatus();
});
watch(() => props.inbox, initializeState, { immediate: true });
@@ -225,7 +232,7 @@ const removeLabel = label => {
// Check if template-related fields have changed
const hasTemplateChanges = () => {
if (!isWhatsAppChannel.value) return false;
if (!isAnyWhatsAppChannel.value) return false;
const original = originalTemplateValues.value;
return (
@@ -254,10 +261,28 @@ const shouldCreateTemplate = () => {
// Build template config for saving
const buildTemplateConfig = () => {
if (!hasExistingTemplate()) return null;
if (!hasExistingTemplate()) {
return null;
}
const { template_name, template_id, template, status } =
templateStatus.value || {};
if (isATwilioWhatsAppChannel.value) {
// Twilio WhatsApp format - get from existing template config
const existingTemplate = props.inbox?.csat_config?.template;
return existingTemplate
? {
friendly_name: existingTemplate.friendly_name,
content_sid: existingTemplate.content_sid,
language: existingTemplate.language || state.templateLanguage,
status: existingTemplate.status || status,
}
: null;
}
// WhatsApp Cloud format
return {
name: template_name,
template_id,
@@ -273,11 +298,11 @@ const updateInbox = async attributes => {
...attributes,
};
return store.dispatch('inboxes/updateInbox', payload);
await store.dispatch('inboxes/updateInbox', payload);
};
const createTemplate = async () => {
if (!isWhatsAppChannel.value) return null;
if (!isAnyWhatsAppChannel.value) return null;
const response = await store.dispatch('inboxes/createCSATTemplate', {
inboxId: props.inbox.id,
@@ -298,7 +323,7 @@ const performSave = async () => {
// For WhatsApp channels, create template first if needed
if (
isWhatsAppChannel.value &&
isAnyWhatsAppChannel.value &&
state.csatSurveyEnabled &&
shouldCreateTemplate()
) {
@@ -326,13 +351,25 @@ const performSave = async () => {
// Use new template data if created, otherwise preserve existing template information
if (newTemplateData) {
csatConfig.template = {
name: newTemplateData.name,
template_id: newTemplateData.template_id,
language: newTemplateData.language,
status: newTemplateData.status,
created_at: new Date().toISOString(),
};
if (isATwilioWhatsAppChannel.value) {
// Twilio WhatsApp template format
csatConfig.template = {
friendly_name: newTemplateData.friendly_name,
content_sid: newTemplateData.content_sid,
language: newTemplateData.language,
status: newTemplateData.status,
created_at: new Date().toISOString(),
};
} else {
// WhatsApp Cloud template format
csatConfig.template = {
name: newTemplateData.name,
template_id: newTemplateData.template_id,
language: newTemplateData.language,
status: newTemplateData.status,
created_at: new Date().toISOString(),
};
}
} else {
const templateConfig = buildTemplateConfig();
if (templateConfig) {
@@ -356,8 +393,9 @@ const performSave = async () => {
const saveSettings = async () => {
// Check if we need to show confirmation dialog for WhatsApp template changes
// This applies to both WhatsApp Cloud and Twilio WhatsApp channels
if (
isWhatsAppChannel.value &&
isAnyWhatsAppChannel.value &&
state.csatSurveyEnabled &&
hasExistingTemplate() &&
hasTemplateChanges()
@@ -390,7 +428,7 @@ const handleConfirmTemplateUpdate = async () => {
<div class="grid gap-5">
<!-- Show display type only for non-WhatsApp channels -->
<WithLabel
v-if="!isWhatsAppChannel"
v-if="!isAnyWhatsAppChannel"
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
name="display_type"
>
@@ -400,7 +438,7 @@ const handleConfirmTemplateUpdate = async () => {
/>
</WithLabel>
<template v-if="isWhatsAppChannel">
<template v-if="isAnyWhatsAppChannel">
<div
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
>
@@ -536,7 +574,7 @@ const handleConfirmTemplateUpdate = async () => {
</WithLabel>
<p class="text-sm italic text-n-slate-11">
{{
isWhatsAppChannel
isAnyWhatsAppChannel
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
: $t('INBOX_MGMT.CSAT.NOTE')
}}
@@ -76,14 +76,14 @@ export default {
businessHours,
};
},
downloadAgentReports() {
downloadConversationReports() {
const { from, to } = this;
const fileName = generateFileName({
type: 'agent',
type: 'conversation',
to,
businessHours: this.businessHours,
});
this.$store.dispatch('downloadAgentReports', {
this.$store.dispatch('downloadConversationsSummaryReports', {
from,
to,
fileName,
@@ -109,10 +109,10 @@ export default {
<template>
<ReportHeader :header-title="$t('REPORT.HEADER')">
<V4Button
:label="$t('REPORT.DOWNLOAD_AGENT_REPORTS')"
:label="$t('REPORT.DOWNLOAD_CONVERSATION_REPORTS')"
icon="i-ph-download-simple"
size="sm"
@click="downloadAgentReports"
@click="downloadConversationReports"
/>
</ReportHeader>
<div class="flex flex-col gap-3">
@@ -78,7 +78,6 @@ const getValueFromConversation = (conversation, attributeKey) => {
case 'team_id':
return conversation.meta?.team?.id;
case 'browser_language':
case 'country_code':
case 'referer':
return conversation.additional_attributes?.[attributeKey];
default:
@@ -234,6 +234,19 @@ export const actions = {
console.error(error);
});
},
downloadConversationsSummaryReports(_, reportObj) {
return Report.getConversationsSummaryReports(reportObj)
.then(response => {
downloadCsvFile(reportObj.fileName, response.data);
AnalyticsHelper.track(REPORTS_EVENTS.DOWNLOAD_REPORT, {
reportType: 'conversations_summary',
businessHours: reportObj?.businessHours,
});
})
.catch(error => {
console.error(error);
});
},
downloadLabelReports(_, reportObj) {
return Report.getLabelReports(reportObj)
.then(response => {
@@ -201,4 +201,24 @@ describe('#actions', () => {
);
});
});
describe('#downloadConversationsSummaryReports', () => {
it('open CSV download prompt if API is success', async () => {
const data = `Conversations,Messages received,Messages sent,Avg first response time,Avg resolution time,Resolution count,Avg customer waiting time
217,323,623,23 hours 22 minutes,179 days 18 hours,30,48 days 4 hours`;
axios.get.mockResolvedValue({ data });
const param = {
from: 1631039400,
to: 1635013800,
fileName: 'conversations-summary-report-24-10-2021.csv',
};
actions.downloadConversationsSummaryReports(1, param);
await flushPromises();
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName,
data
);
});
});
});
-11
View File
@@ -1,11 +0,0 @@
export const OPEN_AI_OPTIONS = {
IMPROVE_WRITING: 'improve_writing',
FIX_SPELLING_GRAMMAR: 'fix_spelling_grammar',
SHORTEN: 'shorten',
EXPAND: 'expand',
MAKE_FRIENDLY: 'make_friendly',
MAKE_FORMAL: 'make_formal',
SIMPLIFY: 'simplify',
REPLY_SUGGESTION: 'reply_suggestion',
SUMMARIZE: 'summarize',
};
+20
View File
@@ -27,6 +27,26 @@ module CaptainFeaturable
}.with_indifferent_access
end
# TODO: Once we support multiple LLM providers, ensure provider hook is correctly looked up
# Currently works because the only model/provider is OpenAI
def openai_hook
@openai_hook ||= hooks.find_by(app_id: 'openai', status: 'enabled')
end
# TODO: Once we support multiple LLM providers, ensure provider key is correctly looked up
# Currently works because the only model/provider is OpenAI
def using_openai_hook_key?
openai_hook&.settings&.dig('api_key').present?
end
def captain_api_key
openai_hook&.settings&.dig('api_key') || captain_system_api_key
end
def captain_system_api_key
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
end
private
def captain_models_with_defaults
+4
View File
@@ -158,6 +158,10 @@ class Inbox < ApplicationRecord
channel_type == 'Channel::Whatsapp'
end
def twilio_whatsapp?
channel_type == 'Channel::TwilioSms' && channel.medium == 'whatsapp'
end
def assignable_agents
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
end
+4 -7
View File
@@ -64,13 +64,10 @@ class Integrations::Hook < ApplicationRecord
update(status: 'disabled')
end
def process_event(event)
case app_id
when 'openai'
Integrations::Openai::ProcessorService.new(hook: self, event: event).perform if app_id == 'openai'
else
{ error: 'No processor found' }
end
def process_event(_event)
# OpenAI integration migrated to Captain::EditorService
# Other integrations (slack, dialogflow, etc.) handled via HookJob
{ error: 'No processor found' }
end
def feature_allowed?
+21
View File
@@ -0,0 +1,21 @@
class Captain::TasksPolicy < ApplicationPolicy
def rewrite?
true
end
def summarize?
true
end
def reply_suggestion?
true
end
def label_suggestion?
true
end
def follow_up?
true
end
end
+40 -2
View File
@@ -6,6 +6,8 @@ class CsatSurveyService
if whatsapp_channel? && template_available_and_approved?
send_whatsapp_template_survey
elsif inbox.twilio_whatsapp? && twilio_template_available_and_approved?
send_twilio_whatsapp_template_survey
elsif within_messaging_window?
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform
else
@@ -45,7 +47,7 @@ class CsatSurveyService
template_config = inbox.csat_config&.dig('template')
return false unless template_config
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
template_name = template_config['name'] || CsatTemplateNameService.csat_template_name(inbox.id)
status_result = inbox.channel.provider_service.get_template_status(template_name)
@@ -55,9 +57,25 @@ class CsatSurveyService
false
end
def twilio_template_available_and_approved?
template_config = inbox.csat_config&.dig('template')
return false unless template_config
content_sid = template_config['content_sid']
return false unless content_sid
template_service = Twilio::CsatTemplateService.new(inbox.channel)
status_result = template_service.get_template_status(content_sid)
status_result[:success] && status_result[:template][:status] == 'approved'
rescue StandardError => e
Rails.logger.error "Error checking Twilio CSAT template status: #{e.message}"
false
end
def send_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
template_name = template_config['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(inbox.id)
template_name = template_config['name'] || CsatTemplateNameService.csat_template_name(inbox.id)
phone_number = conversation.contact_inbox.source_id
template_info = build_template_info(template_name, template_config)
@@ -95,6 +113,26 @@ class CsatSurveyService
)
end
def send_twilio_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
content_sid = template_config['content_sid']
phone_number = conversation.contact_inbox.source_id
content_variables = { '1' => conversation.uuid }
message = build_csat_message
send_service = Twilio::SendOnTwilioService.new(message: message)
result = send_service.send_csat_template_message(
phone_number: phone_number,
content_sid: content_sid,
content_variables: content_variables
)
message.update!(source_id: result[:message_id]) if result[:success] && result[:message_id].present?
rescue StandardError => e
Rails.logger.error "Error sending Twilio WhatsApp CSAT template for conversation #{conversation.id}: #{e.message}"
end
def create_csat_not_sent_activity_message
content = I18n.t('conversations.activity.csat.not_sent_due_to_messaging_window')
activity_message_params = {
@@ -0,0 +1,196 @@
class CsatTemplateManagementService
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
def initialize(inbox)
@inbox = inbox
end
def template_status
template = @inbox.csat_config&.dig('template')
return { template_exists: false } unless template
if @inbox.twilio_whatsapp?
get_twilio_template_status(template)
else
get_whatsapp_template_status(template)
end
rescue StandardError => e
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
{ service_error: e.message }
end
def create_template(template_params)
validate_template_params!(template_params)
delete_existing_template_if_needed
result = create_template_via_provider(template_params)
update_inbox_csat_config(result) if result[:success]
result
rescue StandardError => e
Rails.logger.error "Error creating CSAT template: #{e.message}"
{ success: false, service_error: 'Template creation failed' }
end
private
def validate_template_params!(template_params)
raise ActionController::ParameterMissing, 'message' if template_params[:message].blank?
end
def create_template_via_provider(template_params)
if @inbox.twilio_whatsapp?
create_twilio_template(template_params)
else
create_whatsapp_template(template_params)
end
end
def create_twilio_template(template_params)
template_config = build_template_config(template_params)
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
template_service.create_template(template_config)
end
def create_whatsapp_template(template_params)
template_config = build_template_config(template_params)
Whatsapp::CsatTemplateService.new(@inbox.channel).create_template(template_config)
end
def build_template_config(template_params)
{
message: template_params[:message],
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
language: template_params[:language] || DEFAULT_LANGUAGE,
template_name: CsatTemplateNameService.csat_template_name(@inbox.id)
}
end
def update_inbox_csat_config(result)
current_config = @inbox.csat_config || {}
template_data = build_template_data_from_result(result)
updated_config = current_config.merge('template' => template_data)
@inbox.update!(csat_config: updated_config)
end
def build_template_data_from_result(result)
if @inbox.twilio_whatsapp?
build_twilio_template_data(result)
else
build_whatsapp_cloud_template_data(result)
end
end
def build_twilio_template_data(result)
{
'friendly_name' => result[:friendly_name],
'content_sid' => result[:content_sid],
'approval_sid' => result[:approval_sid],
'language' => result[:language],
'status' => result[:whatsapp_status] || result[:status],
'created_at' => Time.current.iso8601
}.compact
end
def build_whatsapp_cloud_template_data(result)
{
'name' => result[:template_name],
'template_id' => result[:template_id],
'language' => result[:language],
'created_at' => Time.current.iso8601
}
end
def get_twilio_template_status(template)
content_sid = template['content_sid']
return { template_exists: false } unless content_sid
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
status_result = template_service.get_template_status(content_sid)
if status_result[:success]
{
template_exists: true,
friendly_name: template['friendly_name'],
content_sid: template['content_sid'],
status: status_result[:template][:status],
language: template['language']
}
else
{
template_exists: false,
error: 'Template not found'
}
end
end
def get_whatsapp_template_status(template)
template_name = template['name'] || CsatTemplateNameService.csat_template_name(@inbox.id)
status_result = Whatsapp::CsatTemplateService.new(@inbox.channel).get_template_status(template_name)
if status_result[:success]
{
template_exists: true,
template_name: template_name,
status: status_result[:template][:status],
template_id: status_result[:template][:id]
}
else
{
template_exists: false,
error: 'Template not found'
}
end
end
def delete_existing_template_if_needed
template = @inbox.csat_config&.dig('template')
return true if template.blank?
if @inbox.twilio_whatsapp?
delete_existing_twilio_template(template)
else
delete_existing_whatsapp_template(template)
end
rescue StandardError => e
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
false
end
def delete_existing_twilio_template(template)
content_sid = template['content_sid']
return true if content_sid.blank?
template_service = Twilio::CsatTemplateService.new(@inbox.channel)
deletion_result = template_service.delete_template(nil, content_sid)
if deletion_result[:success]
Rails.logger.info "Deleted existing Twilio CSAT template '#{content_sid}' for inbox #{@inbox.id}"
true
else
Rails.logger.warn "Failed to delete existing Twilio CSAT template '#{content_sid}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
false
end
end
def delete_existing_whatsapp_template(template)
template_name = template['name']
return true if template_name.blank?
csat_template_service = Whatsapp::CsatTemplateService.new(@inbox.channel)
template_status = csat_template_service.get_template_status(template_name)
return true unless template_status[:success]
deletion_result = csat_template_service.delete_template(template_name)
if deletion_result[:success]
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
true
else
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
false
end
end
end
@@ -1,4 +1,4 @@
class Whatsapp::CsatTemplateNameService
class CsatTemplateNameService
CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze
# Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number}
@@ -0,0 +1,68 @@
class Twilio::CsatTemplateApiClient
def initialize(twilio_channel)
@twilio_channel = twilio_channel
end
def create_template(request_body)
HTTParty.post(
"#{api_base_path}/v1/Content",
headers: api_headers,
body: request_body.to_json
)
end
def submit_for_approval(approval_url, template_name, category)
request_body = {
name: template_name,
category: category
}
HTTParty.post(
approval_url,
headers: api_headers,
body: request_body.to_json
)
end
def delete_template(content_sid)
HTTParty.delete(
"#{api_base_path}/v1/Content/#{content_sid}",
headers: api_headers
)
end
def fetch_template(content_sid)
HTTParty.get(
"#{api_base_path}/v1/Content/#{content_sid}",
headers: api_headers
)
end
def fetch_approval_status(content_sid)
HTTParty.get(
"#{api_base_path}/v1/Content/#{content_sid}/ApprovalRequests",
headers: api_headers
)
end
private
def api_headers
{
'Authorization' => "Basic #{encoded_credentials}",
'Content-Type' => 'application/json'
}
end
def encoded_credentials
if @twilio_channel.api_key_sid.present?
Base64.strict_encode64("#{@twilio_channel.api_key_sid}:#{@twilio_channel.auth_token}")
else
Base64.strict_encode64("#{@twilio_channel.account_sid}:#{@twilio_channel.auth_token}")
end
end
def api_base_path
'https://content.twilio.com'
end
end
@@ -0,0 +1,204 @@
class Twilio::CsatTemplateService
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
TEMPLATE_CATEGORY = 'UTILITY'.freeze
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
TEMPLATE_CONTENT_TYPE = 'twilio/call-to-action'.freeze
def initialize(twilio_channel)
@twilio_channel = twilio_channel
@api_client = Twilio::CsatTemplateApiClient.new(twilio_channel)
end
def create_template(template_config)
base_name = template_config[:template_name]
template_name = generate_template_name(base_name)
template_config_with_name = template_config.merge(template_name: template_name)
request_body = build_template_request_body(template_config_with_name)
# Step 1: Create template
response = @api_client.create_template(request_body)
return process_template_creation_response(response, template_config_with_name) unless response.success? && response['sid']
# Step 2: Submit for WhatsApp approval using the approval_create URL
approval_url = response.dig('links', 'approval_create')
if approval_url.present?
approval_response = submit_for_whatsapp_approval(approval_url, template_config_with_name[:template_name])
process_approval_response(approval_response, response, template_config_with_name)
else
Rails.logger.warn 'No approval_create URL provided in template creation response'
# Fallback if no approval URL provided
process_template_creation_response(response, template_config_with_name)
end
end
def delete_template(_template_name = nil, content_sid = nil)
content_sid ||= current_template_sid_from_config
return { success: false, error: 'No template to delete' } unless content_sid
response = @api_client.delete_template(content_sid)
{ success: response.success?, response_body: response.body }
end
def get_template_status(content_sid)
return { success: false, error: 'No content SID provided' } unless content_sid
template_response = fetch_template_details(content_sid)
return template_response unless template_response[:success]
approval_response = fetch_approval_status(content_sid)
build_template_status_response(content_sid, template_response[:data], approval_response)
rescue StandardError => e
Rails.logger.error "Error fetching Twilio template status: #{e.message}"
{ success: false, error: e.message }
end
private
def fetch_template_details(content_sid)
response = @api_client.fetch_template(content_sid)
if response.success?
{ success: true, data: response }
else
Rails.logger.error "Failed to get template details: #{response.code} - #{response.body}"
{ success: false, error: 'Template not found' }
end
end
def fetch_approval_status(content_sid)
@api_client.fetch_approval_status(content_sid)
end
def build_template_status_response(content_sid, template_response, approval_response)
if approval_response.success? && approval_response['whatsapp']
build_approved_template_response(content_sid, template_response, approval_response['whatsapp'])
else
build_pending_template_response(content_sid, template_response)
end
end
def build_approved_template_response(content_sid, template_response, whatsapp_data)
{
success: true,
template: {
content_sid: content_sid,
friendly_name: whatsapp_data['name'] || template_response['friendly_name'],
status: whatsapp_data['status'] || 'pending',
language: template_response['language'] || 'en'
}
}
end
def build_pending_template_response(content_sid, template_response)
{
success: true,
template: {
content_sid: content_sid,
friendly_name: template_response['friendly_name'],
status: 'pending',
language: template_response['language'] || 'en'
}
}
end
def generate_template_name(base_name)
current_template_name = current_template_name_from_config
CsatTemplateNameService.generate_next_template_name(base_name, @twilio_channel.inbox.id, current_template_name)
end
def current_template_name_from_config
@twilio_channel.inbox.csat_config&.dig('template', 'friendly_name')
end
def current_template_sid_from_config
@twilio_channel.inbox.csat_config&.dig('template', 'content_sid')
end
def template_exists_in_config?
content_sid = current_template_sid_from_config
friendly_name = current_template_name_from_config
content_sid.present? && friendly_name.present?
end
def build_template_request_body(template_config)
{
friendly_name: template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
variables: {
'1' => '12345' # Example conversation UUID
},
types: {
TEMPLATE_CONTENT_TYPE => {
body: template_config[:message],
actions: [
{
type: 'URL',
title: template_config[:button_text] || DEFAULT_BUTTON_TEXT,
url: "#{template_config[:base_url]}/survey/responses/{{1}}"
}
]
}
}
}
end
def submit_for_whatsapp_approval(approval_url, template_name)
@api_client.submit_for_approval(approval_url, template_name, TEMPLATE_CATEGORY)
end
def process_template_creation_response(response, template_config = {})
if response.success? && response['sid']
{
success: true,
content_sid: response['sid'],
friendly_name: template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
status: TEMPLATE_STATUS_PENDING
}
else
Rails.logger.error "Twilio template creation failed: #{response.code} - #{response.body}"
{
success: false,
error: 'Template creation failed',
response_body: response.body
}
end
end
def process_approval_response(approval_response, creation_response, template_config)
if approval_response.success?
build_successful_approval_response(approval_response, creation_response, template_config)
else
build_failed_approval_response(approval_response, creation_response, template_config)
end
end
def build_successful_approval_response(approval_response, creation_response, template_config)
approval_data = approval_response.parsed_response
{
success: true,
content_sid: creation_response['sid'],
friendly_name: template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
status: TEMPLATE_STATUS_PENDING,
approval_sid: approval_data['sid'],
whatsapp_status: approval_data.dig('whatsapp', 'status') || TEMPLATE_STATUS_PENDING
}
end
def build_failed_approval_response(approval_response, creation_response, template_config)
Rails.logger.error "Twilio template approval submission failed: #{approval_response.code} - #{approval_response.body}"
{
success: true,
content_sid: creation_response['sid'],
friendly_name: template_config[:template_name],
language: template_config[:language] || DEFAULT_LANGUAGE,
status: 'created'
}
end
end
@@ -1,4 +1,24 @@
class Twilio::SendOnTwilioService < Base::SendOnChannelService
def send_csat_template_message(phone_number:, content_sid:, content_variables: {})
send_params = {
to: phone_number,
content_sid: content_sid
}
send_params[:content_variables] = content_variables.to_json if content_variables.present?
send_params[:status_callback] = channel.send(:twilio_delivery_status_index_url) if channel.respond_to?(:twilio_delivery_status_index_url, true)
# Add messaging service or from number
send_params = send_params.merge(channel.send(:send_message_from))
twilio_message = channel.send(:client).messages.create(**send_params)
{ success: true, message_id: twilio_message.sid }
rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e
Rails.logger.error "Failed to send Twilio template message: #{e.message}"
{ success: false, error: e.message }
end
private
def channel_class
@@ -19,7 +19,7 @@ class Whatsapp::CsatTemplateService
end
def delete_template(template_name = nil)
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id)
template_name ||= CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id)
response = HTTParty.delete(
"#{business_account_path}/message_templates?name=#{template_name}",
headers: api_headers
@@ -51,7 +51,7 @@ class Whatsapp::CsatTemplateService
def generate_template_name(base_name)
current_template_name = current_template_name_from_config
Whatsapp::CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name)
CsatTemplateNameService.generate_next_template_name(base_name, @whatsapp_channel.inbox.id, current_template_name)
end
def current_template_name_from_config
@@ -67,7 +67,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
end
def delete_csat_template(template_name = nil)
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id)
template_name ||= CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id)
csat_template_service.delete_template(template_name)
end
@@ -0,0 +1,16 @@
<%= CSVSafe.generate_line [I18n.t('reports.period', since: Date.strptime(params[:since], '%s'), until: Date.strptime(params[:until], '%s'))] %>
<% headers = [
I18n.t('reports.conversation_csv.conversations_count'),
I18n.t('reports.conversation_csv.incoming_messages_count'),
I18n.t('reports.conversation_csv.outgoing_messages_count'),
I18n.t('reports.conversation_csv.avg_first_response_time'),
I18n.t('reports.conversation_csv.avg_resolution_time'),
I18n.t('reports.conversation_csv.resolution_count'),
I18n.t('reports.conversation_csv.avg_customer_waiting_time')
]
%>
<%= CSVSafe.generate_line headers -%>
<% @report_data.each do |row| %>
<%= CSVSafe.generate_line row -%>
<% end %>
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.9.1'
version: '4.9.2'
development:
<<: *shared
+12
View File
@@ -134,6 +134,8 @@ en:
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
stripe_customer_not_configured: Stripe customer not configured
no_payment_method: No payment methods found. Please add a payment method before making a purchase.
reports:
date_range_too_long: Date range cannot exceed 6 months
profile:
mfa:
enabled: MFA enabled successfully
@@ -170,6 +172,14 @@ en:
avg_resolution_time: Avg resolution time
resolution_count: Resolution Count
avg_customer_waiting_time: Avg customer waiting time
conversation_csv:
conversations_count: Conversations
incoming_messages_count: Messages received
outgoing_messages_count: Messages sent
avg_first_response_time: Avg first response time
avg_resolution_time: Avg resolution time
resolution_count: Resolution count
avg_customer_waiting_time: Avg customer waiting time
conversation_traffic_csv:
timezone: Timezone
sla_csv:
@@ -334,6 +344,8 @@ en:
copilot_message_required: Message is required
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
upgrade: 'Upgrade your plan to enable Captain AI'
disabled: 'Captain AI is disabled for this account.'
copilot:
using_tool: 'Using tool %{function_name}'
completed_tool_call: 'Completed %{function_name} tool call'
+9
View File
@@ -73,6 +73,13 @@ Rails.application.routes.draw do
end
resources :custom_tools
resources :documents, only: [:index, :show, :create, :destroy]
resource :tasks, only: [], controller: 'tasks' do
post :rewrite
post :summarize
post :reply_suggestion
post :label_suggestion
post :follow_up
end
end
resource :saml_settings, only: [:show, :create, :update, :destroy]
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
@@ -418,6 +425,7 @@ Rails.application.routes.draw do
get :team
get :inbox
get :label
get :channel
end
end
resources :reports, only: [:index] do
@@ -429,6 +437,7 @@ Rails.application.routes.draw do
get :labels
get :teams
get :conversations
get :conversations_summary
get :conversation_traffic
get :bot_metrics
end
@@ -0,0 +1,32 @@
class RemoveCountryCodeFromConversationFilters < ActiveRecord::Migration[7.1]
def up
CustomFilter.conversation
.where('query::text LIKE ?', '%country_code%')
.find_each do |custom_filter|
query = custom_filter.query || {}
payload = query['payload']
next unless payload.is_a?(Array)
updated_payload = payload.reject do |filter|
filter.is_a?(Hash) && filter['attribute_key'] == 'country_code'
end
next if updated_payload == payload
if updated_payload.empty?
custom_filter.delete
next
end
# rubocop:disable Rails/SkipsModelValidations
# we will skip model validation, since we don't want any callbacks running
custom_filter.update_columns(query: query.merge('payload' => updated_payload))
# rubocop:enable Rails/SkipsModelValidations
end
end
def down
# no-op: removed filters cannot be restored reliably
end
end
@@ -0,0 +1,50 @@
class MigrateCaptainFeaturePreferences < ActiveRecord::Migration[7.1]
def up
migrate_label_suggestion_from_hooks
migrate_audio_transcriptions
end
def down
# Revert captain_features['audio_transcription'] back to audio_transcriptions
Account.find_each do |account|
captain_features = account.settings['captain_features']
next unless captain_features&.[]('audio_transcription') == true
next if account.settings.key?('audio_transcriptions')
account.settings = account.settings.merge('audio_transcriptions' => true)
account.save(validate: false)
end
# NOTE: We cannot reliably revert label_suggestion back to OpenAI hook settings
end
private
def migrate_label_suggestion_from_hooks
Integrations::Hook.where(app_id: 'openai', status: :enabled).find_each do |hook|
next unless hook.settings['label_suggestion'] == true
account = Account.find_by(id: hook.account_id)
next unless account
update_captain_feature(account, 'label_suggestion', true)
end
end
def migrate_audio_transcriptions
Account.where.not("settings->>'audio_transcriptions' IS NULL").find_each do |account|
next unless account.settings['audio_transcriptions'] == true
update_captain_feature(account, 'audio_transcription', true)
end
end
def update_captain_feature(account, feature_key, value)
captain_features = account.settings['captain_features'] || {}
return if captain_features.key?(feature_key)
captain_features[feature_key] = value
account.settings = account.settings.merge('captain_features' => captain_features)
account.save(validate: false)
end
end
+1 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_12_29_081141) do
ActiveRecord::Schema[7.1].define(version: 2026_01_13_141340) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -0,0 +1,70 @@
class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseController
before_action :check_authorization
def rewrite
result = Captain::RewriteService.new(
account: Current.account,
content: params[:content],
operation: params[:operation],
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def summarize
result = Captain::SummaryService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def reply_suggestion
result = Captain::ReplySuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def label_suggestion
result = Captain::LabelSuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def follow_up
result = Captain::FollowUpService.new(
account: Current.account,
follow_up_context: params[:follow_up_context]&.to_unsafe_h,
user_message: params[:message],
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
private
def render_result(result)
if result.nil?
render json: { message: nil }
elsif result[:error]
render json: { error: result[:error] }, status: :unprocessable_entity
else
response_data = { message: result[:message] }
response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
render json: response_data
end
end
def check_authorization
authorize(:'captain/tasks')
end
end
@@ -47,8 +47,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
return process_action('handoff') if handoff_requested?
create_messages
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
account.increment_response_usage
account.increment_response_usage_for_model(account.captain_assistant_model)
end
def collect_previous_messages
@@ -1,22 +0,0 @@
module Concerns::AccountLimitValidation
extend ActiveSupport::Concern
LIMIT_SCHEMA = {
'type' => 'object',
'properties' => {
'inboxes' => { 'type': 'number' },
'agents' => { 'type': 'number' },
'captain_responses' => { 'type': 'number' },
'captain_documents' => { 'type': 'number' }
},
'required' => [],
'additionalProperties' => false
}.freeze
def validate_limit_keys
errors.add(:limits, ': Invalid data') unless self[:limits].is_a? Hash
self[:limits] = {} if self[:limits].blank?
errors.add(:limits, ': Invalid data') unless JSONSchemer.schema(LIMIT_SCHEMA).valid?(self[:limits])
end
end
@@ -1,6 +1,4 @@
module Enterprise::Account::PlanUsageAndLimits
include Concerns::AccountLimitValidation
CAPTAIN_RESPONSES = 'captain_responses'.freeze
CAPTAIN_DOCUMENTS = 'captain_documents'.freeze
CAPTAIN_RESPONSES_USAGE = 'captain_responses_usage'.freeze
@@ -17,18 +15,16 @@ module Enterprise::Account::PlanUsageAndLimits
}
end
def increment_response_usage
increment_sql = <<~SQL.squish
custom_attributes = jsonb_set(
custom_attributes,
'{#{CAPTAIN_RESPONSES_USAGE}}',
to_jsonb(COALESCE((custom_attributes->>'#{CAPTAIN_RESPONSES_USAGE}')::int, 0) + 1),
true
)
SQL
# Skipping validations is safe: no validations on custom_attributes,
updated = self.class.where(id: id).update_all(increment_sql) # rubocop:disable Rails/SkipsModelValidations
reload if updated.positive?
def increment_response_usage(credits: 1)
current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + credits
save
end
def increment_response_usage_for_model(model)
credits = using_openai_hook_key? ? 1 : Llm::Models.credit_multiplier_for(model)
Rails.logger.info("[CAPTAIN] Incrementing response usage for account #{id} by #{credits} credits (model: #{model})")
increment_response_usage(credits: credits)
end
def reset_response_usage
@@ -62,20 +58,10 @@ module Enterprise::Account::PlanUsageAndLimits
def get_captain_limits(type)
total_count = captain_monthly_limit[type.to_s].to_i
usage_key = type == :documents ? CAPTAIN_DOCUMENTS_USAGE : CAPTAIN_RESPONSES_USAGE
consumed = [custom_attributes[usage_key].to_i, 0].max
consumed = if type == :documents
custom_attributes[CAPTAIN_DOCUMENTS_USAGE].to_i || 0
else
custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
end
consumed = 0 if consumed.negative?
{
total_count: total_count,
current_available: (total_count - consumed).clamp(0, total_count),
consumed: consumed
}
{ total_count: total_count, current_available: (total_count - consumed).clamp(0, total_count), consumed: consumed }
end
def default_captain_limits
@@ -118,4 +104,23 @@ module Enterprise::Account::PlanUsageAndLimits
ChatwootApp.max_limit
end
def validate_limit_keys
errors.add(:limits, ': Invalid data') unless self[:limits].is_a? Hash
self[:limits] = {} if self[:limits].blank?
limit_schema = {
'type' => 'object',
'properties' => {
'inboxes' => { 'type': 'number' },
'agents' => { 'type': 'number' },
'captain_responses' => { 'type': 'number' },
'captain_documents' => { 'type': 'number' }
},
'required' => [],
'additionalProperties' => false
}
errors.add(:limits, ': Invalid data') unless JSONSchemer.schema(limit_schema).valid?(self[:limits])
end
end
@@ -4,10 +4,10 @@ class Captain::Copilot::ChatService < Llm::BaseAiService
attr_reader :assistant, :account, :user, :copilot_thread, :previous_history, :messages
def initialize(assistant, config)
super()
@assistant = assistant
@account = assistant.account
super(account: @account)
@user = nil
@copilot_thread = nil
@previous_history = []
@@ -24,16 +24,17 @@ class Captain::Copilot::ChatService < Llm::BaseAiService
response = request_chat_completion
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
Rails.logger.info(
"#{self.class.name} Assistant: #{@assistant.id}, Incrementing response usage for account #{@account.id}"
)
@account.increment_response_usage
increment_usage
response
end
private
def setup_model
@model = @account.captain_copilot_model
end
def setup_user(config)
@user = @account.users.find_by(id: config[:user_id]) if config[:user_id].present?
end
@@ -2,9 +2,10 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
include Captain::ChatHelper
def initialize(assistant: nil, conversation_id: nil)
super()
@assistant = assistant
@account = @assistant&.account
super(account: @account)
@conversation_id = conversation_id
@messages = [system_message]
@@ -27,6 +28,10 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
private
def setup_model
@model = @account&.captain_assistant_model || DEFAULT_MODEL
end
def build_tools
[Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
end
@@ -8,7 +8,8 @@ class Llm::BaseAiService
attr_reader :model, :temperature
def initialize
def initialize(account: nil)
@account = account
Llm::Config.initialize!
setup_model
setup_temperature
@@ -18,6 +19,12 @@ class Llm::BaseAiService
RubyLLM.chat(model: model).with_temperature(temperature)
end
def increment_usage
return unless @account
@account.increment_response_usage_for_model(@model)
end
private
def setup_model
@@ -84,7 +84,8 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
attachment.update!(meta: { transcribed_text: transcribed_text })
message.reload.send_update_event
message.account.increment_response_usage
# TODO: Once we support configurable transcription models, update credit calculation
message.account.increment_response_usage(credits: 1)
return unless ChatwootApp.advanced_search_allowed?
@@ -0,0 +1,33 @@
module Enterprise::Captain::BaseTaskService
def perform
return { error: I18n.t('captain.copilot_limit'), error_code: 429 } unless responses_available?
unless captain_enabled?
return { error: I18n.t('captain.upgrade') } if ChatwootApp.chatwoot_cloud?
return { error: I18n.t('captain.disabled') }
end
result = super
increment_usage if successful_result?(result)
result
end
private
def captain_enabled?
account.feature_enabled?('captain_integration')
end
def responses_available?
account.usage_limits[:captain][:responses][:current_available].positive?
end
def successful_result?(result)
result.is_a?(Hash) && result[:message].present? && !result[:error]
end
def increment_usage
account.increment_response_usage_for_model(configured_model)
end
end
@@ -1,82 +0,0 @@
module Enterprise::Integrations::OpenaiProcessorService
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion label_suggestion fix_spelling_grammar shorten expand
make_friendly make_formal simplify].freeze
CACHEABLE_EVENTS = %w[label_suggestion].freeze
def label_suggestion_message
payload = label_suggestion_body
return nil if payload.blank?
response = make_api_call(label_suggestion_body)
return response if response[:error].present?
# LLMs are not deterministic, so this is bandaid solution
# To what you ask? Sometimes, the response includes
# "Labels:" in it's response in some format. This is a hacky way to remove it
# TODO: Fix with with a better prompt
{ message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' }
end
private
def labels_with_messages
return nil unless valid_conversation?(conversation)
labels = hook.account.labels.pluck(:title).join(', ')
character_count = labels.length
messages = init_messages_body(false)
add_messages_until_token_limit(conversation, messages, false, character_count)
return nil if messages.blank? || labels.blank?
"Messages:\n#{messages}\nLabels:\n#{labels}"
end
def valid_conversation?(conversation)
return false if conversation.nil?
return false if conversation.messages.incoming.count < 3
# Think Mark think, at this point the conversation is beyond saving
return false if conversation.messages.count > 100
# if there are more than 20 messages, only trigger this if the last message is from the client
return false if conversation.messages.count > 20 && !conversation.messages.last.incoming?
true
end
def summarize_body
{
model: self.class::GPT_MODEL,
messages: [
{ role: 'system',
content: prompt_from_file('summary', enterprise: true) },
{ role: 'user', content: conversation_messages }
]
}.to_json
end
def label_suggestion_body
return unless label_suggestions_enabled?
content = labels_with_messages
return value_from_cache if content.blank?
{
model: self.class::GPT_MODEL,
messages: [
{
role: 'system',
content: prompt_from_file('label_suggestion', enterprise: true)
},
{ role: 'user', content: content }
]
}.to_json
end
def label_suggestions_enabled?
hook.settings['label_suggestion'].present?
end
end
+161
View File
@@ -0,0 +1,161 @@
class Captain::BaseTaskService
include Integrations::LlmInstrumentation
# gpt-4o-mini supports 128,000 tokens
# 1 token is approx 4 characters
# sticking with 120000 to be safe
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
TOKEN_LIMIT = 400_000
GPT_MODEL = Llm::Config::DEFAULT_MODEL
# Prepend enterprise module to subclasses when they're defined.
# This ensures the enterprise perform wrapper is applied even when
# subclasses define their own perform method, since prepend puts
# the module before the class in the ancestor chain.
def self.inherited(subclass)
super
subclass.prepend_mod_with('Captain::BaseTaskService')
end
pattr_initialize [:account!, { conversation_display_id: nil }]
private
def event_name
raise NotImplementedError, "#{self.class} must implement #event_name"
end
def feature_key
raise NotImplementedError, "#{self.class} must implement #feature_key"
end
def configured_model
account.public_send("captain_#{feature_key}_model")
end
def conversation
@conversation ||= account.conversations.find_by(display_id: conversation_display_id)
end
def api_base
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
endpoint = endpoint.chomp('/')
"#{endpoint}/v1"
end
def make_api_call(model:, messages:)
instrumentation_params = build_instrumentation_params(model, messages)
response = instrument_llm_call(instrumentation_params) do
execute_ruby_llm_request(model: model, messages: messages)
end
# Build follow-up context for client-side refinement, when applicable
if build_follow_up_context? && response[:message].present?
response.merge(follow_up_context: build_follow_up_context(messages, response))
else
response
end
end
def execute_ruby_llm_request(model:, messages:)
Llm::Config.with_api_key(account.captain_api_key, api_base: api_base) do |context|
chat = context.chat(model: model)
system_msg = messages.find { |m| m[:role] == 'system' }
chat.with_instructions(system_msg[:content]) if system_msg
conversation_messages = messages.reject { |m| m[:role] == 'system' }
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
add_messages_if_needed(chat, conversation_messages)
response = chat.ask(conversation_messages.last[:content])
build_ruby_llm_response(response, messages)
end
rescue StandardError => e
# TODO: RubyLLM throws the RubyLLM::Error when any API based error occurs
# Just like inboxes have a reauthroizable error handling mechanism,
# we should handle the errors if the account is using their own key.
# If more than 5 errors occur, we disable the hook and notify the admins
# see: https://rubyllm.com/error-handling/#rubyllm-error-hierarchy
ChatwootExceptionTracker.new(e, account: account).capture_exception
{ error: e.message, request_messages: messages }
end
def add_messages_if_needed(chat, conversation_messages)
return if conversation_messages.length == 1
conversation_messages[0...-1].each do |msg|
chat.add_message(role: msg[:role].to_sym, content: msg[:content])
end
end
def build_ruby_llm_response(response, messages)
{
message: response.content,
usage: {
'prompt_tokens' => response.input_tokens,
'completion_tokens' => response.output_tokens,
'total_tokens' => (response.input_tokens || 0) + (response.output_tokens || 0)
},
request_messages: messages
}
end
def build_instrumentation_params(model, messages)
{
span_name: "llm.#{event_name}",
account_id: account.id,
conversation_id: conversation&.display_id,
feature_name: event_name,
model: model,
messages: messages,
temperature: nil
}
end
def conversation_messages(start_from: 0)
messages = []
character_count = start_from
conversation.messages
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.reorder('id desc')
.each do |message|
content = message.content_for_llm
break unless content.present? && character_count + content.length <= TOKEN_LIMIT
messages.prepend({ role: (message.incoming? ? 'user' : 'assistant'), content: content })
character_count += content.length
end
messages
end
def prompt_from_file(file_name)
Rails.root.join('lib/integrations/openai/openai_prompts', "#{file_name}.liquid").read
end
# Follow-up context for client-side refinement
def build_follow_up_context?
# FollowUpService should return its own updated context
!is_a?(Captain::FollowUpService)
end
def build_follow_up_context(messages, response)
{
event_name: event_name,
original_context: extract_original_context(messages),
last_response: response[:message],
conversation_history: []
}
end
def extract_original_context(messages)
# Get the most recent user message for follow-up context
user_msg = messages.reverse.find { |m| m[:role] == 'user' }
user_msg ? user_msg[:content] : nil
end
end
Captain::BaseTaskService.prepend_mod_with('Captain::BaseTaskService')
+102
View File
@@ -0,0 +1,102 @@
class Captain::FollowUpService < Captain::BaseTaskService
pattr_initialize [:account!, :follow_up_context!, :user_message!, { conversation_display_id: nil }]
ALLOWED_EVENT_NAMES = %w[
professional
casual
friendly
confident
straightforward
fix_spelling_grammar
improve
summarize
reply_suggestion
label_suggestion
].freeze
def perform
return { error: 'Follow-up context missing', error_code: 400 } unless valid_follow_up_context?
# Build context-aware system prompt
system_prompt = build_follow_up_system_prompt(follow_up_context)
# Build full message array (convert history from string keys to symbol keys)
history = follow_up_context['conversation_history'].to_a.map do |msg|
{ role: msg['role'], content: msg['content'] }
end
messages = [
{ role: 'system', content: system_prompt },
{ role: 'user', content: follow_up_context['original_context'] },
{ role: 'assistant', content: follow_up_context['last_response'] },
*history,
{ role: 'user', content: user_message }
]
response = make_api_call(model: configured_model, messages: messages)
return response if response[:error]
response.merge(follow_up_context: update_follow_up_context(user_message, response[:message]))
end
private
def build_follow_up_system_prompt(session_data)
action_context = describe_previous_action(session_data['event_name'])
<<~PROMPT
You just performed a #{action_context} action for a customer support agent.
Your job now is to help them refine the result based on their feedback.
Be concise and focused on their specific request.
PROMPT
end
def describe_previous_action(event_name)
case event_name
when 'professional', 'casual', 'friendly', 'confident', 'straightforward'
"tone rewrite (#{event_name})"
when 'fix_spelling_grammar'
'spelling and grammar correction'
when 'improve'
'message improvement'
when 'summarize'
'conversation summary'
when 'reply_suggestion'
'reply suggestion'
when 'label_suggestion'
'label suggestion'
else
event_name
end
end
def valid_follow_up_context?
return false unless follow_up_context.is_a?(Hash)
return false unless ALLOWED_EVENT_NAMES.include?(follow_up_context['event_name'])
required_keys = %w[event_name original_context last_response]
required_keys.all? { |key| follow_up_context[key].present? }
end
def update_follow_up_context(user_msg, assistant_msg)
updated_history = follow_up_context['conversation_history'].to_a + [
{ 'role' => 'user', 'content' => user_msg },
{ 'role' => 'assistant', 'content' => assistant_msg }
]
{
'event_name' => follow_up_context['event_name'],
'original_context' => follow_up_context['original_context'],
'last_response' => assistant_msg,
'conversation_history' => updated_history
}
end
def event_name
'follow_up'
end
def feature_key
'editor'
end
end
+97
View File
@@ -0,0 +1,97 @@
class Captain::LabelSuggestionService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!]
def perform
# Check cache first
cached_response = read_from_cache
return cached_response if cached_response.present?
# Build content
content = labels_with_messages
return nil if content.blank?
# Make API call
response = make_api_call(
model: configured_model,
messages: [
{ role: 'system', content: prompt_from_file('label_suggestion') },
{ role: 'user', content: content }
]
)
return response if response[:error].present?
# Clean up response
result = { message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' }
# Cache successful result
write_to_cache(result)
result
end
private
def cache_key
return nil unless conversation
format(
::Redis::Alfred::OPENAI_CONVERSATION_KEY,
event_name: 'label_suggestion',
conversation_id: conversation.id,
updated_at: conversation.last_activity_at.to_i
)
end
def read_from_cache
return nil unless cache_key
cached = Redis::Alfred.get(cache_key)
JSON.parse(cached, symbolize_names: true) if cached.present?
rescue JSON::ParserError
nil
end
def write_to_cache(response)
Redis::Alfred.setex(cache_key, response.to_json) if cache_key
end
def labels_with_messages
return nil unless valid_conversation?(conversation)
labels = account.labels.pluck(:title).join(', ')
messages = format_messages_as_string(start_from: labels.length)
return nil if messages.blank? || labels.blank?
"Messages:\n#{messages}\nLabels:\n#{labels}"
end
def format_messages_as_string(start_from: 0)
messages = conversation_messages(start_from: start_from)
messages.map do |msg|
sender_type = msg[:role] == 'user' ? 'Customer' : 'Agent'
"#{sender_type}: #{msg[:content]}\n"
end.join
end
def valid_conversation?(conversation)
return false if conversation.nil?
return false if conversation.messages.incoming.count < 3
return false if conversation.messages.count > 100
return false if conversation.messages.count > 20 && !conversation.messages.last.incoming?
true
end
def event_name
'label_suggestion'
end
def feature_key
'label_suggestion'
end
def build_follow_up_context?
false
end
end
+22
View File
@@ -0,0 +1,22 @@
class Captain::ReplySuggestionService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!]
def perform
make_api_call(
model: configured_model,
messages: [
{ role: 'system', content: prompt_from_file('reply') }
].concat(conversation_messages)
)
end
private
def event_name
'reply_suggestion'
end
def feature_key
'editor'
end
end
+63
View File
@@ -0,0 +1,63 @@
class Captain::RewriteService < Captain::BaseTaskService
pattr_initialize [:account!, :content!, :operation!, { conversation_display_id: nil }]
TONE_OPERATIONS = %i[casual professional friendly confident straightforward].freeze
ALLOWED_OPERATIONS = (%i[fix_spelling_grammar improve] + TONE_OPERATIONS).freeze
def perform
operation_sym = operation.to_sym
raise ArgumentError, "Invalid operation: #{operation}" unless ALLOWED_OPERATIONS.include?(operation_sym)
send(operation_sym)
end
TONE_OPERATIONS.each do |tone|
define_method(tone) do
call_llm_with_prompt(tone_rewrite_prompt(tone.to_s))
end
end
private
def fix_spelling_grammar
call_llm_with_prompt(prompt_from_file('fix_spelling_grammar'))
end
def improve
template = prompt_from_file('improve')
system_prompt = render_liquid_template(template, {
'conversation_context' => conversation.to_llm_text(include_contact_details: true),
'draft_message' => content
})
call_llm_with_prompt(system_prompt, content)
end
def call_llm_with_prompt(system_content, user_content = content)
make_api_call(
model: configured_model,
messages: [
{ role: 'system', content: system_content },
{ role: 'user', content: user_content }
]
)
end
def render_liquid_template(template_content, variables = {})
Liquid::Template.parse(template_content).render(variables)
end
def tone_rewrite_prompt(tone)
template = prompt_from_file('tone_rewrite')
render_liquid_template(template, 'tone' => tone)
end
def event_name
operation
end
def feature_key
'editor'
end
end
+23
View File
@@ -0,0 +1,23 @@
class Captain::SummaryService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!]
def perform
make_api_call(
model: configured_model,
messages: [
{ role: 'system', content: prompt_from_file('summary') },
{ role: 'user', content: conversation.to_llm_text(include_contact_details: false) }
]
)
end
private
def event_name
'summarize'
end
def feature_key
'editor'
end
end
-6
View File
@@ -86,12 +86,6 @@ conversations:
filter_operators:
- "equal_to"
- "not_equal_to"
country_code:
attribute_type: "additional_attributes"
data_type: "text"
filter_operators:
- "equal_to"
- "not_equal_to"
referer:
attribute_type: "additional_attributes"
data_type: "link"
+2 -1
View File
@@ -7,7 +7,8 @@ class Integrations::LlmBaseService
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
TOKEN_LIMIT = 400_000
GPT_MODEL = Llm::Config::DEFAULT_MODEL
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze
ALLOWED_EVENT_NAMES = %w[summarize reply_suggestion fix_spelling_grammar casual professional friendly confident
straightforward improve].freeze
CACHEABLE_EVENTS = %w[].freeze
pattr_initialize [:hook!, :event!]
@@ -0,0 +1,17 @@
You are an AI writing assistant integrated into Chatwoot, an omnichannel customer support platform. Your task is to fix grammar and spelling in a customer support message while preserving the original meaning, intent, and tone.
You will receive a message and must return a corrected version with only grammar, spelling, and punctuation fixes applied.
Important guidelines:
- Preserve the original meaning, intent, and tone exactly
- Do not rephrase, rewrite, or change wording beyond grammar, spelling, and punctuation
- Do not add or remove any information
- Do not simplify, shorten, or expand the message
- Ensure the output remains appropriate for customer support
Super Important:
- If the message has some markdown formatting, keep the formatting as it is.
- Block quotes (lines starting with >) contain quoted text from the customer's previous message. Preserve this quoted text exactly as written (do not modify the customer's words inside the block quote), but DO improve the agent's reply that follows the block quote.
- Ensure the output is in the user's original language
Output only the corrected message, with no preamble, tags, or explanation.
@@ -0,0 +1,43 @@
You are a writing assistant for customer support agents. Your task is to improve a draft message by enhancing its language, clarity, and tone—not by adding new content.
<conversation_context>
{{ conversation_context }}
</conversation_context>
<draft_message>
{{ draft_message }}
</draft_message>
## Your Task
Rewrite the draft to be clearer, warmer, and more professional while preserving the agent's intent.
## What "Improve" Means
Improve the **quality** of the message, not the **quantity** of information:
| DO | DON'T |
|-----|--------|
| Fix grammar, spelling, punctuation | Add new information or steps |
| Improve sentence structure and flow | Expand scope beyond the draft |
| Make tone warmer and more professional | Add offers ("I can also...", "Would you like...") |
| Use contact's name naturally | Invent technical details, links, or examples |
| Make vague phrases more natural | Turn a brief answer into a long one |
## Using the Context
Use the conversation context to:
- Understand what's being discussed (so improvements make sense)
- Gauge appropriate tone (formal/casual, frustrated customer, etc.)
- Personalize with the contact's name when natural
Do NOT use the context to fill in gaps or add information the agent didn't include.
## Output Rules
- Keep the improved message at a similar length to the draft (brief stays brief)
- Preserve any markdown formatting
- Block quotes (lines starting with `>`) contain quoted customer text—keep this unchanged, only improve the agent's reply
- Output in the same language as the draft
- Output only the improved message, no commentary
@@ -1 +1 @@
Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a customer and the support agent, along with a list of potential labels. Your task is to analyze the conversation and select the two labels from the given list that most accurately represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain the labels you\'ve selected,in their original casing, and nothing else.
Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a customer and the support agent, along with a list of potential labels. Your task is to analyze the conversation and select the two labels from the given list that most accurately represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain the labels you've selected,in their original casing, and nothing else.
@@ -1,13 +1,13 @@
As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
Make sure you strongly adhere to the following rules when generating the summary
1. Be brief and concise. The shorter the summary the better.
2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
1. Be brief and concise. The shorter the summary the better.
2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
3. Describe the customer intent in around 50 words.
4. Remove information that is not directly relevant to the customer's problem or the agent's solution. For example, personal anecdotes, small talk, etc.
5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell.
6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
7. 'Action Items' should strictly encapsulate tasks committed to by the agent or left incomplete. Any suggestions made by the agent should not be included.
8. The 'Action Items' should be brief and concise
9. Mark important words or parts of sentences as bold.
@@ -25,4 +25,4 @@ Reply in the user's language, as a markdown of the following format.
**Action Items**
**Follow-up Items**
**Follow-up Items**
@@ -1 +0,0 @@
Please summarize the key points from the following conversation between support agents and customer as bullet points for the next support agent looking into the conversation. Reply in the user's language.
@@ -0,0 +1,35 @@
You are an AI writing assistant integrated into Chatwoot, an omnichannel customer support platform. Your task is to rewrite customer support message to match a specific tone while preserving the original meaning and intent.
Here is the tone to apply to the message you will receive:
<tone_instruction>
{% case tone %}
{% when 'friendly' %}
Warm, approachable, and personable. Use conversational language, positive words, and show empathy. May include phrases like "Happy to help!" or "I'd be glad to..."
{% when 'confident' %}
Assertive and assured. Use definitive language, avoid hedging words like "maybe" or "I think". Be direct and authoritative while remaining helpful.
{% when 'straightforward' %}
Clear, direct, and to-the-point. Remove unnecessary words, get straight to the information or solution. No fluff or extra pleasantries.
{% when 'casual' %}
Relaxed and informal. Use contractions, simpler words, and a conversational style. Friendly but less formal than professional tone.
{% when 'professional' %}
Formal, polished, and business-appropriate. Use complete sentences, proper grammar, and maintain respectful distance. Avoid slang or overly casual language.
{% else %}
Warm, approachable, and personable. Use conversational language, positive words, and show empathy. May include phrases like "Happy to help!" or "I'd be glad to..."
{% endcase %}
</tone_instruction>
Your task is to rewrite the message according to the specified tone instructions.
Important guidelines:
- Preserve the core meaning and all important information from the original message
- Keep the rewritten message concise and appropriate for customer support
- Maintain helpfulness and respect regardless of tone
- Do not add information that wasn't in the original message
- Do not remove critical details or instructions
Super Important:
- If the message has some markdown formatting, keep the formatting as it is.
- Block quotes (lines starting with >) contain quoted text from the customer's previous message. Preserve this quoted text exactly as written (do not modify the customer's words inside the block quote), but DO improve the agent's reply that follows the block quote.
- Ensure the output is in the user's original language
Output only the rewritten message without any preamble, tags or explanation.
@@ -1,138 +0,0 @@
class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
AGENT_INSTRUCTION = 'You are a helpful support agent.'.freeze
LANGUAGE_INSTRUCTION = 'Ensure that the reply should be in user language.'.freeze
def reply_suggestion_message
make_api_call(reply_suggestion_body)
end
def summarize_message
make_api_call(summarize_body)
end
def rephrase_message
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please rephrase the following response. " \
"#{LANGUAGE_INSTRUCTION}"))
end
def fix_spelling_grammar_message
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please fix the spelling and grammar of the following response. " \
"#{LANGUAGE_INSTRUCTION}"))
end
def shorten_message
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please shorten the following response. " \
"#{LANGUAGE_INSTRUCTION}"))
end
def expand_message
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please expand the following response. " \
"#{LANGUAGE_INSTRUCTION}"))
end
def make_friendly_message
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more friendly. " \
"#{LANGUAGE_INSTRUCTION}"))
end
def make_formal_message
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more formal. " \
"#{LANGUAGE_INSTRUCTION}"))
end
def simplify_message
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please simplify the following response. " \
"#{LANGUAGE_INSTRUCTION}"))
end
private
def prompt_from_file(file_name, enterprise: false)
path = enterprise ? 'enterprise/lib/enterprise/integrations/openai_prompts' : 'lib/integrations/openai/openai_prompts'
Rails.root.join(path, "#{file_name}.txt").read
end
def build_api_call_body(system_content, user_content = event['data']['content'])
{
model: GPT_MODEL,
messages: [
{ role: 'system', content: system_content },
{ role: 'user', content: user_content }
]
}.to_json
end
def conversation_messages(in_array_format: false)
messages = init_messages_body(in_array_format)
add_messages_until_token_limit(conversation, messages, in_array_format)
end
def add_messages_until_token_limit(conversation, messages, in_array_format, start_from = 0)
character_count = start_from
conversation.messages.where(message_type: [:incoming, :outgoing]).where(private: false).reorder('id desc').each do |message|
character_count, message_added = add_message_if_within_limit(character_count, message, messages, in_array_format)
break unless message_added
end
messages
end
def add_message_if_within_limit(character_count, message, messages, in_array_format)
content = message.content_for_llm
if valid_message?(content, character_count)
add_message_to_list(message, messages, in_array_format, content)
character_count += content.length
[character_count, true]
else
[character_count, false]
end
end
def valid_message?(content, character_count)
content.present? && character_count + content.length <= TOKEN_LIMIT
end
def add_message_to_list(message, messages, in_array_format, content)
formatted_message = format_message(message, in_array_format, content)
messages.prepend(formatted_message)
end
def init_messages_body(in_array_format)
in_array_format ? [] : ''
end
def format_message(message, in_array_format, content)
in_array_format ? format_message_in_array(message, content) : format_message_in_string(message, content)
end
def format_message_in_array(message, content)
{ role: (message.incoming? ? 'user' : 'assistant'), content: content }
end
def format_message_in_string(message, content)
sender_type = message.incoming? ? 'Customer' : 'Agent'
"#{sender_type} #{message.sender&.name} : #{content}\n"
end
def summarize_body
{
model: GPT_MODEL,
messages: [
{ role: 'system',
content: prompt_from_file('summary', enterprise: false) },
{ role: 'user', content: conversation_messages }
]
}.to_json
end
def reply_suggestion_body
{
model: GPT_MODEL,
messages: [
{ role: 'system',
content: prompt_from_file('reply', enterprise: false) }
].concat(conversation_messages(in_array_format: true))
}.to_json
end
end
Integrations::Openai::ProcessorService.prepend_mod_with('Integrations::OpenaiProcessorService')
+2 -1
View File
@@ -1,7 +1,8 @@
require 'ruby_llm'
module Llm::Config
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
class << self
def initialized?
@initialized ||= false
+4
View File
@@ -19,6 +19,10 @@ module Llm::Models
models_for(feature).include?(model_name.to_s)
end
def credit_multiplier_for(model_name)
CONFIG.dig('models', model_name.to_s, 'credit_multiplier') || 1
end
def feature_config(feature_key)
feature = features[feature_key.to_s]
return nil unless feature
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.9.1",
"version": "4.9.2",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -39,6 +39,7 @@
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@highlightjs/vue-plugin": "^2.1.0",
"@iconify-json/fluent": "^1.2.32",
"@iconify-json/material-symbols": "^1.2.10",
"@lk77/vue3-color": "^3.0.6",
"@radix-ui/colors": "^3.0.0",
+10
View File
@@ -40,6 +40,9 @@ importers:
'@highlightjs/vue-plugin':
specifier: ^2.1.0
version: 2.1.0(highlight.js@11.10.0)(vue@3.5.12(typescript@5.6.2))
'@iconify-json/fluent':
specifier: ^1.2.32
version: 1.2.36
'@iconify-json/material-symbols':
specifier: ^1.2.10
version: 1.2.10
@@ -961,6 +964,9 @@ packages:
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
deprecated: Use @eslint/object-schema instead
'@iconify-json/fluent@1.2.36':
resolution: {integrity: sha512-DhxwOu5Qiq09o2ehHeUK0I9lC01OeWFlxXP7pIslM0vbi8VuplrrJ7kMg21GJy87iOCevzxf6gTU7TLPGgSknw==}
'@iconify-json/logos@1.2.10':
resolution: {integrity: sha512-qxaXKJ6fu8jzTMPQdHtNxlfx6tBQ0jXRbHZIYy5Ilh8Lx9US9FsAdzZWUR8MXV8PnWTKGDFO4ZZee9VwerCyMA==}
@@ -5513,6 +5519,10 @@ snapshots:
'@humanwhocodes/object-schema@2.0.3': {}
'@iconify-json/fluent@1.2.36':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/logos@1.2.10':
dependencies:
'@iconify/types': 2.0.0
@@ -0,0 +1,92 @@
require 'rails_helper'
RSpec.describe V2::Reports::ChannelSummaryBuilder do
let!(:account) { create(:account) }
let!(:web_widget_inbox) { create(:inbox, account: account) }
let!(:email_inbox) { create(:inbox, :with_email, account: account) }
let(:params) do
{
since: 1.week.ago.beginning_of_day,
until: Time.current.end_of_day
}
end
let(:builder) { described_class.new(account: account, params: params) }
describe '#build' do
subject(:report) { builder.build }
context 'when there are conversations with different statuses across channels' do
before do
# Web widget conversations
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 2.days.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 3.days.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.days.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :pending, created_at: 1.day.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :snoozed, created_at: 1.day.ago)
# Email conversations
create(:conversation, account: account, inbox: email_inbox, status: :open, created_at: 2.days.ago)
create(:conversation, account: account, inbox: email_inbox, status: :resolved, created_at: 1.day.ago)
create(:conversation, account: account, inbox: email_inbox, status: :resolved, created_at: 3.days.ago)
end
it 'returns correct counts grouped by channel type' do
expect(report['Channel::WebWidget']).to eq(
open: 2,
resolved: 1,
pending: 1,
snoozed: 1,
total: 5
)
expect(report['Channel::Email']).to eq(
open: 1,
resolved: 2,
pending: 0,
snoozed: 0,
total: 3
)
end
end
context 'when conversations are outside the date range' do
before do
create(:conversation, account: account, inbox: web_widget_inbox, status: :open, created_at: 2.days.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.weeks.ago)
end
it 'only includes conversations within the date range' do
expect(report['Channel::WebWidget']).to eq(
open: 1,
resolved: 0,
pending: 0,
snoozed: 0,
total: 1
)
end
end
context 'when there are no conversations' do
it 'returns an empty hash' do
expect(report).to eq({})
end
end
context 'when a channel has only one status type' do
before do
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 1.day.ago)
create(:conversation, account: account, inbox: web_widget_inbox, status: :resolved, created_at: 2.days.ago)
end
it 'returns zeros for other statuses' do
expect(report['Channel::WebWidget']).to eq(
open: 0,
resolved: 2,
pending: 0,
snoozed: 0,
total: 2
)
end
end
end
end
+4 -9
View File
@@ -10,19 +10,14 @@ RSpec.describe 'API Base', type: :request do
let!(:conversation) { create(:conversation, account: account) }
it 'sets Current attributes for the request and then returns the response' do
# expect Current.account_user is set to the admin's account_user
allow(Current).to receive(:user=).and_call_original
allow(Current).to receive(:account=).and_call_original
allow(Current).to receive(:account_user=).and_call_original
# This test verifies that Current.user, Current.account, and Current.account_user
# are properly set during request processing. We verify this indirectly:
# - A successful response proves Current.account_user was set (required for authorization)
# - The correct conversation data proves Current.account was set (scopes the query)
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}",
headers: { api_access_token: admin.access_token.token },
as: :json
expect(Current).to have_received(:user=).with(admin).at_least(:once)
expect(Current).to have_received(:account=).with(account).at_least(:once)
expect(Current).to have_received(:account_user=).with(admin.account_users.first).at_least(:once)
expect(response).to have_http_status(:success)
expect(response.parsed_body['id']).to eq(conversation.display_id)
end

Some files were not shown because too many files have changed in this diff Show More