Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
870cd8d3c0 | ||
|
|
292f6f1b18 | ||
|
|
39d6fa06ea | ||
|
|
05de508b7c | ||
|
|
bb9f07d662 |
@@ -48,7 +48,6 @@
|
||||
- Remove dead/unreachable/unused code
|
||||
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
||||
- Don't reference Claude in commit messages
|
||||
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
||||
|
||||
## Project-Specific
|
||||
|
||||
@@ -79,4 +78,3 @@ Practical checklist for any change impacting core logic or public APIs
|
||||
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
|
||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
||||
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
|
||||
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
|
||||
|
||||
@@ -827,7 +827,6 @@ GEM
|
||||
faraday-net_http (>= 1)
|
||||
faraday-retry (>= 1)
|
||||
marcel (~> 1.0)
|
||||
ruby_llm-schema (~> 0.2.1)
|
||||
zeitwerk (~> 2)
|
||||
ruby_llm-schema (0.2.5)
|
||||
ruby_parser (3.20.0)
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
class YearInReviewBuilder
|
||||
attr_reader :account, :user_id, :year
|
||||
|
||||
def initialize(account:, user_id:, year:)
|
||||
@account = account
|
||||
@user_id = user_id
|
||||
@year = year
|
||||
end
|
||||
|
||||
def build
|
||||
{
|
||||
year: year,
|
||||
total_conversations: total_conversations_count,
|
||||
busiest_day: busiest_day_data,
|
||||
support_personality: support_personality_data
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def year_range
|
||||
@year_range ||= begin
|
||||
start_time = Time.zone.local(year, 1, 1).beginning_of_day
|
||||
end_time = Time.zone.local(year, 12, 31).end_of_day
|
||||
start_time..end_time
|
||||
end
|
||||
end
|
||||
|
||||
def total_conversations_count
|
||||
account.conversations
|
||||
.where(assignee_id: user_id, created_at: year_range)
|
||||
.count
|
||||
end
|
||||
|
||||
def busiest_day_data
|
||||
daily_counts = account.conversations
|
||||
.where(assignee_id: user_id, created_at: year_range)
|
||||
.group_by_day(:created_at, range: year_range, time_zone: Time.zone)
|
||||
.count
|
||||
|
||||
return nil if daily_counts.empty?
|
||||
|
||||
busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
|
||||
|
||||
return nil if count.zero?
|
||||
|
||||
{
|
||||
date: busiest_date.strftime('%b %d'),
|
||||
count: count
|
||||
}
|
||||
end
|
||||
|
||||
def support_personality_data
|
||||
response_time = average_response_time
|
||||
|
||||
return { avg_response_time_seconds: 0 } if response_time.nil?
|
||||
|
||||
{
|
||||
avg_response_time_seconds: response_time.to_i
|
||||
}
|
||||
end
|
||||
|
||||
def average_response_time
|
||||
avg_time = account.reporting_events
|
||||
.where(
|
||||
name: 'first_response',
|
||||
user_id: user_id,
|
||||
created_at: year_range
|
||||
)
|
||||
.average(:value)
|
||||
|
||||
avg_time&.to_f
|
||||
end
|
||||
end
|
||||
@@ -1,160 +0,0 @@
|
||||
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
|
||||
|
||||
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
|
||||
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)
|
||||
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
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
authorize @inbox, :show?
|
||||
end
|
||||
|
||||
def validate_whatsapp_channel
|
||||
return if @inbox.whatsapp?
|
||||
|
||||
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
|
||||
status: :bad_request
|
||||
end
|
||||
|
||||
def extract_template_params
|
||||
params.require(:template).permit(:message, :button_text, :language)
|
||||
end
|
||||
|
||||
def render_missing_message_error
|
||||
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)
|
||||
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
|
||||
end
|
||||
|
||||
def render_failed_template_creation(result)
|
||||
whatsapp_error = parse_whatsapp_error(result[:response_body])
|
||||
error_message = whatsapp_error[:user_message] || result[:error]
|
||||
|
||||
render json: {
|
||||
error: error_message,
|
||||
details: whatsapp_error[:technical_details]
|
||||
}, 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?
|
||||
|
||||
begin
|
||||
error_data = JSON.parse(response_body)
|
||||
whatsapp_error = error_data['error'] || {}
|
||||
|
||||
user_message = whatsapp_error['error_user_msg'] || whatsapp_error['message']
|
||||
technical_details = {
|
||||
code: whatsapp_error['code'],
|
||||
subcode: whatsapp_error['error_subcode'],
|
||||
type: whatsapp_error['type'],
|
||||
title: whatsapp_error['error_user_title']
|
||||
}.compact
|
||||
|
||||
{ user_message: user_message, technical_details: technical_details }
|
||||
rescue JSON::ParserError
|
||||
{ user_message: nil, technical_details: response_body }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -152,37 +152,31 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def format_csat_config(config)
|
||||
formatted = {
|
||||
'display_type' => config['display_type'] || 'emoji',
|
||||
'message' => config['message'] || '',
|
||||
:survey_rules => {
|
||||
'operator' => config.dig('survey_rules', 'operator') || 'contains',
|
||||
'values' => config.dig('survey_rules', 'values') || []
|
||||
},
|
||||
'button_text' => config['button_text'] || 'Please rate us',
|
||||
'language' => config['language'] || 'en'
|
||||
{
|
||||
display_type: config['display_type'] || 'emoji',
|
||||
message: config['message'] || '',
|
||||
survey_rules: {
|
||||
operator: config.dig('survey_rules', 'operator') || 'contains',
|
||||
values: config.dig('survey_rules', 'values') || []
|
||||
}
|
||||
}
|
||||
format_template_config(config, formatted)
|
||||
formatted
|
||||
end
|
||||
|
||||
def format_template_config(config, formatted)
|
||||
formatted['template'] = config['template'] if config['template'].present?
|
||||
end
|
||||
|
||||
def inbox_attributes
|
||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||
: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] }] }]
|
||||
{ csat_config: [:display_type, :message, { survey_rules: [:operator, { values: [] }] }] }]
|
||||
end
|
||||
|
||||
def permitted_params(channel_attributes = [])
|
||||
# We will remove this line after fixing https://linear.app/chatwoot/issue/CW-1567/null-value-passed-as-null-string-to-backend
|
||||
params.each { |k, v| params[k] = params[k] == 'null' ? nil : v }
|
||||
params.permit(*inbox_attributes, channel: [:type, *channel_attributes])
|
||||
|
||||
params.permit(
|
||||
*inbox_attributes,
|
||||
channel: [:type, *channel_attributes]
|
||||
)
|
||||
end
|
||||
|
||||
def channel_type_from_params
|
||||
@@ -198,7 +192,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def get_channel_attributes(channel_type)
|
||||
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||
if channel_type.constantize.const_defined?(:EDITABLE_ATTRS)
|
||||
channel_type.constantize::EDITABLE_ATTRS.presence
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController
|
||||
def show
|
||||
year = params[:year] || 2025
|
||||
cache_key = "year_in_review_#{Current.account.id}_#{year}"
|
||||
|
||||
cached_data = Current.user.ui_settings&.dig(cache_key)
|
||||
|
||||
if cached_data.present?
|
||||
render json: cached_data
|
||||
else
|
||||
builder = YearInReviewBuilder.new(
|
||||
account: Current.account,
|
||||
user_id: Current.user.id,
|
||||
year: year
|
||||
)
|
||||
|
||||
data = builder.build
|
||||
|
||||
ui_settings = Current.user.ui_settings || {}
|
||||
ui_settings[cache_key] = data
|
||||
Current.user.update(ui_settings: ui_settings)
|
||||
|
||||
render json: data
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,16 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class YearInReviewAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('year_in_review', { accountScoped: true, apiVersion: 'v2' });
|
||||
}
|
||||
|
||||
get(year) {
|
||||
return axios.get(`${this.url}`, {
|
||||
params: { year },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new YearInReviewAPI();
|
||||
-9
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { getBusinessHoursConfig } from 'dashboard/helper/slaHelper';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
@@ -41,17 +40,9 @@ const slaStatusText = computed(() => {
|
||||
});
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
const businessHoursConfig = getBusinessHoursConfig(
|
||||
appliedSLA.value?.slaPolicy,
|
||||
props.conversation?.inbox
|
||||
);
|
||||
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
|
||||
chat: props.conversation,
|
||||
options: businessHoursConfig
|
||||
? { businessHours: businessHoursConfig }
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ const props = defineProps({
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
@@ -107,7 +106,6 @@ watch(
|
||||
:allow-signature="allowSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
|
||||
+12
-14
@@ -7,7 +7,7 @@ import { vOnClickOutside } from '@vueuse/components';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
@@ -50,6 +50,12 @@ const EmojiInput = defineAsyncComponent(
|
||||
() => import('shared/components/emoji/EmojiInput.vue')
|
||||
);
|
||||
|
||||
const signatureToApply = computed(() =>
|
||||
props.isEmailOrWebWidgetInbox
|
||||
? props.messageSignature
|
||||
: extractTextFromMarkdown(props.messageSignature)
|
||||
);
|
||||
|
||||
const {
|
||||
fetchSignatureFlagFromUISettings,
|
||||
setSignatureFlagForInbox,
|
||||
@@ -74,20 +80,12 @@ const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
});
|
||||
|
||||
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
||||
|
||||
const shouldShowSignatureButton = computed(() => {
|
||||
return (
|
||||
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
|
||||
);
|
||||
});
|
||||
|
||||
const setSignature = () => {
|
||||
if (props.messageSignature) {
|
||||
if (signatureToApply.value) {
|
||||
if (sendWithSignature.value) {
|
||||
emit('addSignature', props.messageSignature);
|
||||
emit('addSignature', signatureToApply.value);
|
||||
} else {
|
||||
emit('removeSignature', props.messageSignature);
|
||||
emit('removeSignature', signatureToApply.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -103,7 +101,7 @@ watch(
|
||||
() => props.hasSelectedInbox,
|
||||
newValue => {
|
||||
nextTick(() => {
|
||||
if (newValue && !isVoiceInbox.value) setSignature();
|
||||
if (newValue && props.isEmailOrWebWidgetInbox) setSignature();
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -222,7 +220,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</FileUpload>
|
||||
<Button
|
||||
v-if="shouldShowSignatureButton"
|
||||
v-if="hasSelectedInbox && isRegularMessageMode"
|
||||
icon="i-lucide-signature"
|
||||
color="slate"
|
||||
size="sm"
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ const removeAttachment = id => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-4 max-h-48 overflow-y-auto">
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<div
|
||||
v-if="filteredImageAttachments.length > 0"
|
||||
class="flex flex-wrap gap-3"
|
||||
|
||||
+10
-28
@@ -6,7 +6,7 @@ import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
buildContactableInboxesList,
|
||||
@@ -87,12 +87,6 @@ const whatsappMessageTemplates = computed(() =>
|
||||
|
||||
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
|
||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||
);
|
||||
|
||||
const validationRules = computed(() => ({
|
||||
selectedContact: { required },
|
||||
targetInbox: { required },
|
||||
@@ -200,7 +194,6 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||
|
||||
const handleInboxAction = ({ value, action, ...rest }) => {
|
||||
v$.value.$reset();
|
||||
state.message = '';
|
||||
emit('updateTargetInbox', { ...rest });
|
||||
showInboxesDropdown.value = false;
|
||||
state.attachedFiles = [];
|
||||
@@ -209,28 +202,25 @@ const handleInboxAction = ({ value, action, ...rest }) => {
|
||||
const removeSignatureFromMessage = () => {
|
||||
// Always remove the signature from message content when inbox/contact is removed
|
||||
// to ensure no leftover signature content remains
|
||||
if (props.messageSignature) {
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
props.messageSignature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
||||
? props.messageSignature
|
||||
: extractTextFromMarkdown(props.messageSignature);
|
||||
if (signatureToRemove) {
|
||||
state.message = removeSignature(state.message, signatureToRemove);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
removeSignatureFromMessage();
|
||||
state.message = '';
|
||||
emit('updateTargetInbox', value);
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
removeSignatureFromMessage();
|
||||
emit('clearSelectedContact');
|
||||
state.message = '';
|
||||
state.attachedFiles = [];
|
||||
removeSignatureFromMessage();
|
||||
};
|
||||
|
||||
const onClickInsertEmoji = emoji => {
|
||||
@@ -238,19 +228,11 @@ const onClickInsertEmoji = emoji => {
|
||||
};
|
||||
|
||||
const handleAddSignature = signature => {
|
||||
state.message = appendSignature(
|
||||
state.message,
|
||||
signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
state.message = appendSignature(state.message, signature);
|
||||
};
|
||||
|
||||
const handleRemoveSignature = signature => {
|
||||
state.message = removeSignature(
|
||||
state.message,
|
||||
signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
state.message = removeSignature(state.message, signature);
|
||||
};
|
||||
|
||||
const handleAttachFile = files => {
|
||||
@@ -374,10 +356,10 @@ const shouldShowMessageEditor = computed(() => {
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:has-attachments="state.attachedFiles.length > 0"
|
||||
:channel-type="inboxChannelType"
|
||||
:medium="targetInbox?.medium || ''"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
|
||||
+102
-24
@@ -1,49 +1,127 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ref, watch, computed, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
appendSignature,
|
||||
extractTextFromMarkdown,
|
||||
removeSignature,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
|
||||
|
||||
const props = defineProps({
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, required: true },
|
||||
hasErrors: { type: Boolean, default: false },
|
||||
hasAttachments: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const editorKey = computed(() => `editor-${props.channelType}-${props.medium}`);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const state = ref({
|
||||
hasSlashCommand: false,
|
||||
showMentions: false,
|
||||
mentionSearchKey: '',
|
||||
});
|
||||
|
||||
const plainTextSignature = computed(() =>
|
||||
extractTextFromMarkdown(props.messageSignature)
|
||||
);
|
||||
|
||||
watch(
|
||||
modelValue,
|
||||
newValue => {
|
||||
if (props.isEmailOrWebWidgetInbox) return;
|
||||
|
||||
const bodyWithoutSignature = newValue
|
||||
? removeSignature(newValue, plainTextSignature.value)
|
||||
: '';
|
||||
|
||||
// Check if message starts with slash
|
||||
const startsWithSlash = bodyWithoutSignature.startsWith('/');
|
||||
|
||||
// Update slash command and mentions state
|
||||
state.value = {
|
||||
...state.value,
|
||||
hasSlashCommand: startsWithSlash,
|
||||
showMentions: startsWithSlash,
|
||||
mentionSearchKey: startsWithSlash ? bodyWithoutSignature.slice(1) : '',
|
||||
};
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const hideMention = () => {
|
||||
state.value.showMentions = false;
|
||||
};
|
||||
|
||||
const replaceText = async message => {
|
||||
// Only append signature on replace if sendWithSignature is true
|
||||
const finalMessage = props.sendWithSignature
|
||||
? appendSignature(message, plainTextSignature.value)
|
||||
: message;
|
||||
|
||||
await nextTick();
|
||||
modelValue.value = finalMessage;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
||||
<Editor
|
||||
:key="editorKey"
|
||||
v-model="modelValue"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
:medium="medium"
|
||||
/>
|
||||
<template v-if="isEmailOrWebWidgetInbox">
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<TextArea
|
||||
v-model="modelValue"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
|
||||
:custom-text-area-class="
|
||||
hasErrors
|
||||
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
|
||||
: ''
|
||||
"
|
||||
auto-height
|
||||
allow-signature
|
||||
:signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
>
|
||||
<CannedResponse
|
||||
v-if="state.showMentions && state.hasSlashCommand"
|
||||
v-on-clickaway="hideMention"
|
||||
class="normal-editor__canned-box"
|
||||
:search-key="state.mentionSearchKey"
|
||||
@replace="replaceText"
|
||||
/>
|
||||
</TextArea>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -14,7 +14,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
|
||||
import ChannelLeaf from './ChannelLeaf.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
@@ -652,7 +651,6 @@ const menuItems = computed(() => {
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
||||
/>
|
||||
<YearInReviewBanner />
|
||||
<SidebarChangelogCard
|
||||
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { computed } from 'vue';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
|
||||
|
||||
import {
|
||||
DropdownContainer,
|
||||
@@ -25,8 +22,6 @@ defineOptions({
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
||||
@@ -36,29 +31,6 @@ const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const showYearInReviewModal = ref(false);
|
||||
|
||||
const bannerClosedKey = computed(() => {
|
||||
return `yir_closed_${accountId.value}_2025`;
|
||||
});
|
||||
|
||||
const isBannerClosed = computed(() => {
|
||||
return uiSettings.value?.[bannerClosedKey.value] === true;
|
||||
});
|
||||
|
||||
const showYearInReviewMenuItem = computed(() => {
|
||||
return route.query['year-in-review'] === 'true' && isBannerClosed.value;
|
||||
});
|
||||
|
||||
const openYearInReviewModal = () => {
|
||||
showYearInReviewModal.value = true;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const closeYearInReviewModal = () => {
|
||||
showYearInReviewModal.value = false;
|
||||
};
|
||||
|
||||
const showChatSupport = computed(() => {
|
||||
return (
|
||||
isFeatureEnabledonAccount.value(
|
||||
@@ -70,13 +42,6 @@ const showChatSupport = computed(() => {
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
show: showYearInReviewMenuItem.value,
|
||||
showOnCustomBrandedInstance: false,
|
||||
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
|
||||
icon: 'i-lucide-gift',
|
||||
click: openYearInReviewModal,
|
||||
},
|
||||
{
|
||||
show: showChatSupport.value,
|
||||
showOnCustomBrandedInstance: false,
|
||||
@@ -192,9 +157,4 @@ const allowedMenuItems = computed(() => {
|
||||
</template>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
|
||||
<YearInReviewModal
|
||||
:show="showYearInReviewModal"
|
||||
@close="closeYearInReviewModal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { toPng } from 'html-to-image';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
slideElement: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
slideBackground: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
year: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const isGenerating = ref(false);
|
||||
const shareImageUrl = ref(null);
|
||||
|
||||
const generateImage = async () => {
|
||||
if (!props.slideElement) return;
|
||||
|
||||
isGenerating.value = true;
|
||||
try {
|
||||
let slideElement = props.slideElement;
|
||||
|
||||
if (slideElement && '$el' in slideElement) {
|
||||
slideElement = slideElement.$el;
|
||||
}
|
||||
|
||||
if (!slideElement) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('No slide element found');
|
||||
return;
|
||||
}
|
||||
|
||||
const colorMap = {
|
||||
'bg-[#5BD58A]': '#5BD58A',
|
||||
'bg-[#60a5fa]': '#60a5fa',
|
||||
'bg-[#fb923c]': '#fb923c',
|
||||
'bg-[#f87171]': '#f87171',
|
||||
'bg-[#fbbf24]': '#fbbf24',
|
||||
};
|
||||
const bgColor = colorMap[props.slideBackground] || '#ffffff';
|
||||
|
||||
const dataUrl = await toPng(slideElement, {
|
||||
quality: 2,
|
||||
pixelRatio: 1.2,
|
||||
backgroundColor: bgColor,
|
||||
});
|
||||
|
||||
const img = new Image();
|
||||
img.src = dataUrl;
|
||||
await new Promise(resolve => {
|
||||
img.onload = resolve;
|
||||
});
|
||||
|
||||
const finalCanvas = document.createElement('canvas');
|
||||
const borderSize = 20;
|
||||
const bottomPadding = 50;
|
||||
|
||||
finalCanvas.width = img.width + borderSize * 2;
|
||||
finalCanvas.height = img.height + borderSize * 2 + bottomPadding;
|
||||
|
||||
const ctx = finalCanvas.getContext('2d');
|
||||
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, finalCanvas.width, finalCanvas.height);
|
||||
|
||||
ctx.drawImage(img, borderSize, borderSize);
|
||||
|
||||
ctx.fillStyle = '#1f2d3d';
|
||||
ctx.font = 'normal 16px system-ui, -apple-system, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(
|
||||
t('YEAR_IN_REVIEW.SHARE_MODAL.BRANDING'),
|
||||
borderSize,
|
||||
img.height + borderSize + 35
|
||||
);
|
||||
|
||||
const logo = new Image();
|
||||
logo.src = '/brand-assets/logo.svg';
|
||||
await new Promise(resolve => {
|
||||
logo.onload = resolve;
|
||||
});
|
||||
|
||||
const logoHeight = 30;
|
||||
const logoWidth = (logo.width / logo.height) * logoHeight;
|
||||
const logoX = finalCanvas.width - borderSize - logoWidth;
|
||||
const logoY = img.height + borderSize + 15;
|
||||
|
||||
ctx.drawImage(logo, logoX, logoY, logoWidth, logoHeight);
|
||||
|
||||
shareImageUrl.value = finalCanvas.toDataURL('image/png');
|
||||
} catch (err) {
|
||||
// Handle errors silently for now
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to generate image:', err);
|
||||
} finally {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImage = () => {
|
||||
if (!shareImageUrl.value) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = shareImageUrl.value;
|
||||
link.download = `chatwoot-year-in-review-${props.year}.png`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const shareImage = async () => {
|
||||
if (!shareImageUrl.value) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(shareImageUrl.value);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], `chatwoot-year-in-review-${props.year}.png`, {
|
||||
type: 'image/png',
|
||||
});
|
||||
|
||||
if (
|
||||
navigator.share &&
|
||||
navigator.canShare &&
|
||||
navigator.canShare({ files: [file] })
|
||||
) {
|
||||
await navigator.share({
|
||||
title: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TITLE', {
|
||||
year: props.year,
|
||||
}),
|
||||
text: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TEXT', { year: props.year }),
|
||||
files: [file],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
downloadImage();
|
||||
} catch (err) {
|
||||
// Fallback to download if sharing fails
|
||||
downloadImage();
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
shareImageUrl.value = null;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleOpen = async () => {
|
||||
if (props.show && !shareImageUrl.value) {
|
||||
await generateImage();
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ handleOpen });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-[10001]"
|
||||
@click="close"
|
||||
>
|
||||
<div v-if="isGenerating" class="flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="inline-block w-12 h-12 border-4 rounded-full border-white border-t-transparent animate-spin"
|
||||
/>
|
||||
<p class="mt-4 text-sm text-white">
|
||||
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.PREPARING') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="shareImageUrl"
|
||||
class="max-w-2xl w-full mx-4 flex flex-col gap-6 bg-slate-800 rounded-2xl p-6"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xl font-medium text-white">
|
||||
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.TITLE') }}
|
||||
</h3>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full text-white hover:bg-white hover:bg-opacity-20 transition-colors"
|
||||
@click="close"
|
||||
>
|
||||
<i class="i-lucide-x w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<img
|
||||
:src="shareImageUrl"
|
||||
alt="Year in Review"
|
||||
class="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="flex-[2] px-4 py-3 flex items-center justify-center gap-2 rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||
@click="downloadImage"
|
||||
>
|
||||
<i class="i-lucide-download w-5 h-5" />
|
||||
<span class="text-sm font-medium">{{
|
||||
t('YEAR_IN_REVIEW.SHARE_MODAL.DOWNLOAD')
|
||||
}}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||
@click="shareImage"
|
||||
>
|
||||
<i class="i-lucide-share-2 w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -1,93 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import YearInReviewModal from './YearInReviewModal.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const yearInReviewBannerImage =
|
||||
'/assets/images/dashboard/year-in-review/year-in-review-sidebar.png';
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const getters = useStoreGetters();
|
||||
const showModal = ref(false);
|
||||
const modalRef = ref(null);
|
||||
|
||||
const currentYear = 2025;
|
||||
|
||||
const isACustomBrandedInstance =
|
||||
getters['globalConfig/isACustomBrandedInstance'];
|
||||
|
||||
const bannerClosedKey = computed(() => {
|
||||
const accountId = getters.getCurrentAccountId.value;
|
||||
return `yir_closed_${accountId}_${currentYear}`;
|
||||
});
|
||||
|
||||
const isBannerClosed = computed(() => {
|
||||
return uiSettings.value?.[bannerClosedKey.value] === true;
|
||||
});
|
||||
|
||||
const shouldShowBanner = computed(
|
||||
() =>
|
||||
route.query['year-in-review'] === 'true' &&
|
||||
!isBannerClosed.value &&
|
||||
!isACustomBrandedInstance.value
|
||||
);
|
||||
|
||||
const openModal = () => {
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false;
|
||||
};
|
||||
|
||||
const closeBanner = event => {
|
||||
event.stopPropagation();
|
||||
updateUISettings({ [bannerClosedKey.value]: true });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="shouldShowBanner" class="relative">
|
||||
<div
|
||||
class="mx-2 my-1 p-3 bg-n-iris-9 rounded-lg cursor-pointer hover:shadow-md transition-all"
|
||||
@click="openModal"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2 mb-3">
|
||||
<span
|
||||
class="text-sm font-semibold text-white leading-tight tracking-tight flex-1"
|
||||
>
|
||||
{{ t('YEAR_IN_REVIEW.BANNER.TITLE', { year: currentYear }) }}
|
||||
</span>
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded hover:bg-white hover:bg-opacity-20 transition-colors p-0"
|
||||
@click="closeBanner"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-x size-4 mt-0.5 text-n-slate-1 dark:text-n-slate-12"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<img
|
||||
:src="yearInReviewBannerImage"
|
||||
alt="Year in Review"
|
||||
class="w-full h-auto rounded"
|
||||
/>
|
||||
<button
|
||||
class="w-full px-3 py-2 bg-white text-n-iris-9 text-xs font-medium rounded-mdtracking-tight"
|
||||
@click.stop="openModal"
|
||||
>
|
||||
{{ t('YEAR_IN_REVIEW.BANNER.BUTTON') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<YearInReviewModal ref="modalRef" :show="showModal" @close="closeModal" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,384 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import YearInReviewAPI from 'dashboard/api/yearInReview';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import IntroSlide from './slides/IntroSlide.vue';
|
||||
import ConversationsSlide from './slides/ConversationsSlide.vue';
|
||||
import BusiestDaySlide from './slides/BusiestDaySlide.vue';
|
||||
import PersonalitySlide from './slides/PersonalitySlide.vue';
|
||||
import ThankYouSlide from './slides/ThankYouSlide.vue';
|
||||
import ShareModal from './ShareModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { uiSettings } = useUISettings();
|
||||
const getters = useStoreGetters();
|
||||
const isOpen = ref(false);
|
||||
const currentSlide = ref(0);
|
||||
const yearData = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
const slideRefs = ref([]);
|
||||
const showShareModal = ref(false);
|
||||
const shareModalRef = ref(null);
|
||||
const drumrollAudio = ref(null);
|
||||
|
||||
const hasConversations = computed(() => {
|
||||
return yearData.value?.total_conversations > 0;
|
||||
});
|
||||
|
||||
const totalSlides = computed(() => {
|
||||
if (!hasConversations.value) {
|
||||
return 3;
|
||||
}
|
||||
return 5;
|
||||
});
|
||||
|
||||
const slideIndexMap = computed(() => {
|
||||
if (!hasConversations.value) {
|
||||
return [0, 1, 4];
|
||||
}
|
||||
return [0, 1, 2, 3, 4];
|
||||
});
|
||||
|
||||
const currentVisualSlide = computed(() => {
|
||||
return slideIndexMap.value.indexOf(currentSlide.value);
|
||||
});
|
||||
|
||||
const slideBackgrounds = [
|
||||
'bg-[#5BD58A]',
|
||||
'bg-[#60a5fa]',
|
||||
'bg-[#fb923c]',
|
||||
'bg-[#f87171]',
|
||||
'bg-[#fbbf24]',
|
||||
];
|
||||
|
||||
const playDrumroll = () => {
|
||||
try {
|
||||
if (!drumrollAudio.value) {
|
||||
drumrollAudio.value = new Audio('/audio/dashboard/drumroll.mp3');
|
||||
drumrollAudio.value.volume = 0.5;
|
||||
}
|
||||
|
||||
drumrollAudio.value.currentTime = 0;
|
||||
drumrollAudio.value.play().catch(err => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Could not play drumroll:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Error playing drumroll:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchYearInReviewData = async () => {
|
||||
const year = 2025;
|
||||
const accountId = getters.getCurrentAccountId.value;
|
||||
const cacheKey = `year_in_review_${accountId}_${year}`;
|
||||
|
||||
const cachedData = uiSettings.value?.[cacheKey];
|
||||
|
||||
if (cachedData) {
|
||||
yearData.value = cachedData;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await YearInReviewAPI.get(year);
|
||||
yearData.value = response.data;
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const nextSlide = () => {
|
||||
if (currentSlide.value < 4) {
|
||||
if (!hasConversations.value && currentSlide.value === 1) {
|
||||
currentSlide.value = 4;
|
||||
} else {
|
||||
currentSlide.value += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const previousSlide = () => {
|
||||
if (currentSlide.value > 0) {
|
||||
if (!hasConversations.value && currentSlide.value === 4) {
|
||||
currentSlide.value = 1;
|
||||
} else {
|
||||
currentSlide.value -= 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const goToSlide = visualIndex => {
|
||||
currentSlide.value = slideIndexMap.value[visualIndex];
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
currentSlide.value = 0;
|
||||
isOpen.value = false;
|
||||
yearData.value = null;
|
||||
isLoading.value = false;
|
||||
error.value = null;
|
||||
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
isOpen.value = true;
|
||||
fetchYearInReviewData();
|
||||
playDrumroll();
|
||||
};
|
||||
|
||||
const currentSlideBackground = computed(
|
||||
() => slideBackgrounds[currentSlide.value]
|
||||
);
|
||||
|
||||
const shareCurrentSlide = async () => {
|
||||
showShareModal.value = true;
|
||||
nextTick(() => {
|
||||
if (shareModalRef.value) {
|
||||
shareModalRef.value.handleOpen();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const closeShareModal = () => {
|
||||
showShareModal.value = false;
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: { action: close },
|
||||
ArrowLeft: { action: previousSlide },
|
||||
ArrowRight: { action: nextSlide },
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
defineExpose({ open, close });
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
newValue => {
|
||||
if (newValue) {
|
||||
open();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[9999] bg-black font-interDisplay"
|
||||
>
|
||||
<div class="relative w-full h-full overflow-hidden">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center w-full h-full bg-n-slate-2"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="inline-block w-12 h-12 border-4 rounded-full border-n-slate-6 border-t-n-slate-11 animate-spin"
|
||||
/>
|
||||
<p class="mt-4 text-sm text-n-slate-11">
|
||||
{{ t('YEAR_IN_REVIEW.LOADING') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex items-center justify-center w-full h-full bg-n-slate-2"
|
||||
>
|
||||
<div class="text-center">
|
||||
<p class="text-lg font-semibold text-red-600">
|
||||
{{ t('YEAR_IN_REVIEW.ERROR') }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-n-slate-11">{{ error }}</p>
|
||||
<button
|
||||
class="mt-4 px-4 py-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||
@click="close"
|
||||
>
|
||||
<span class="text-sm font-medium">{{
|
||||
t('YEAR_IN_REVIEW.CLOSE')
|
||||
}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="yearData"
|
||||
class="relative w-full h-full"
|
||||
:class="currentSlideBackground"
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-x-[30px]"
|
||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||
>
|
||||
<IntroSlide
|
||||
v-if="currentSlide === 0"
|
||||
:key="0"
|
||||
:ref="el => (slideRefs[0] = el)"
|
||||
:year="yearData.year"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-x-[30px]"
|
||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||
>
|
||||
<ConversationsSlide
|
||||
v-if="currentSlide === 1"
|
||||
:key="1"
|
||||
:ref="el => (slideRefs[1] = el)"
|
||||
:total-conversations="yearData.total_conversations"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-x-[30px]"
|
||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||
>
|
||||
<BusiestDaySlide
|
||||
v-if="
|
||||
currentSlide === 2 && hasConversations && yearData.busiest_day
|
||||
"
|
||||
:key="2"
|
||||
:ref="el => (slideRefs[2] = el)"
|
||||
:busiest-day="yearData.busiest_day"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-x-[30px]"
|
||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||
>
|
||||
<PersonalitySlide
|
||||
v-if="
|
||||
currentSlide === 3 &&
|
||||
hasConversations &&
|
||||
yearData.support_personality
|
||||
"
|
||||
:key="3"
|
||||
:ref="el => (slideRefs[3] = el)"
|
||||
:support-personality="yearData.support_personality"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
leave-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-x-[30px]"
|
||||
leave-to-class="opacity-0 -translate-x-[30px]"
|
||||
>
|
||||
<ThankYouSlide
|
||||
v-if="currentSlide === 4"
|
||||
:key="4"
|
||||
:ref="el => (slideRefs[4] = el)"
|
||||
:year="yearData.year"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div
|
||||
class="absolute bottom-8 left-0 right-0 flex items-center justify-between px-8"
|
||||
>
|
||||
<button
|
||||
v-if="currentSlide > 0"
|
||||
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||
@click="previousSlide"
|
||||
>
|
||||
<i class="i-lucide-chevron-left w-5 h-5" />
|
||||
<span class="text-sm font-medium">
|
||||
{{ t('YEAR_IN_REVIEW.NAVIGATION.PREVIOUS') }}
|
||||
</span>
|
||||
</button>
|
||||
<div v-else class="w-20" />
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="index in totalSlides"
|
||||
:key="index"
|
||||
class="w-2 h-2 rounded-full transition-all"
|
||||
:class="
|
||||
currentVisualSlide === index - 1
|
||||
? 'bg-white w-8'
|
||||
: 'bg-white bg-opacity-50'
|
||||
"
|
||||
@click="goToSlide(index - 1)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||
:class="{ invisible: currentVisualSlide === totalSlides - 1 }"
|
||||
@click="nextSlide"
|
||||
>
|
||||
<span
|
||||
v-if="currentVisualSlide < totalSlides - 1"
|
||||
class="text-sm font-medium"
|
||||
>
|
||||
{{ t('YEAR_IN_REVIEW.NAVIGATION.NEXT') }}
|
||||
</span>
|
||||
<i
|
||||
v-if="currentVisualSlide < totalSlides - 1"
|
||||
class="i-lucide-chevron-right w-5 h-5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="absolute top-4 left-4 px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
|
||||
@click="shareCurrentSlide"
|
||||
>
|
||||
<i class="i-lucide-share-2 w-5 h-5" />
|
||||
<span class="text-sm font-medium">{{
|
||||
t('YEAR_IN_REVIEW.NAVIGATION.SHARE')
|
||||
}}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full text-n-slate-12 dark:text-n-slate-1 hover:bg-white hover:bg-opacity-20 transition-colors"
|
||||
@click="close"
|
||||
>
|
||||
<i class="i-lucide-x w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShareModal
|
||||
ref="shareModalRef"
|
||||
:show="showShareModal"
|
||||
:slide-element="slideRefs[currentSlide]"
|
||||
:slide-background="currentSlideBackground"
|
||||
:year="yearData?.year"
|
||||
@close="closeShareModal"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -1,76 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
busiestDay: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const coffeeImage =
|
||||
'/assets/images/dashboard/year-in-review/third-frame-coffee.png';
|
||||
const doubleQuotesImage =
|
||||
'/assets/images/dashboard/year-in-review/double-quotes.png';
|
||||
|
||||
const performanceHelperText = computed(() => {
|
||||
const count = props.busiestDay.count;
|
||||
if (count <= 5) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.0_5');
|
||||
if (count <= 10) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.5_10');
|
||||
if (count <= 25) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.10_25');
|
||||
if (count <= 50) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.25_50');
|
||||
if (count <= 100) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.50_100');
|
||||
if (count <= 500) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.100_500');
|
||||
return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.500_PLUS');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
|
||||
<div class="flex flex-col gap-4 w-full max-w-3xl">
|
||||
<div class="flex items-center justify-center flex-1">
|
||||
<div class="flex items-center justify-between gap-6 flex-1 md:gap-16">
|
||||
<div class="text-white flex gap-2 flex-col">
|
||||
<div class="text-2xl lg:text-3xl xl:text-4xl tracking-tight">
|
||||
{{ t('YEAR_IN_REVIEW.BUSIEST_DAY.TITLE') }}
|
||||
</div>
|
||||
<div class="text-6xl md:text-8xl lg:text-[140px] tracking-tighter">
|
||||
{{ busiestDay.date }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
:src="coffeeImage"
|
||||
alt="Coffee"
|
||||
class="w-auto h-32 md:h-56 lg:h-72"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<div class="flex items-center justify-center gap-3 md:gap-8">
|
||||
<img
|
||||
:src="doubleQuotesImage"
|
||||
alt="Quote"
|
||||
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<p
|
||||
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
|
||||
>
|
||||
{{
|
||||
t('YEAR_IN_REVIEW.BUSIEST_DAY.MESSAGE', {
|
||||
count: busiestDay.count,
|
||||
})
|
||||
}}
|
||||
{{ performanceHelperText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,94 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
totalConversations: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const cloudImage =
|
||||
'/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png';
|
||||
const doubleQuotesImage =
|
||||
'/assets/images/dashboard/year-in-review/double-quotes.png';
|
||||
|
||||
const hasData = computed(() => {
|
||||
return props.totalConversations > 0;
|
||||
});
|
||||
|
||||
const formatNumber = num => {
|
||||
if (num >= 100000) {
|
||||
return '100k+';
|
||||
}
|
||||
return new Intl.NumberFormat().format(num);
|
||||
};
|
||||
|
||||
const performanceHelperText = computed(() => {
|
||||
if (!hasData.value) {
|
||||
return t('YEAR_IN_REVIEW.CONVERSATIONS.FALLBACK');
|
||||
}
|
||||
|
||||
const count = props.totalConversations;
|
||||
if (count <= 50) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.0_50');
|
||||
if (count <= 100) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.50_100');
|
||||
if (count <= 500) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.100_500');
|
||||
if (count <= 2000)
|
||||
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.500_2000');
|
||||
if (count <= 10000)
|
||||
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.2000_10000');
|
||||
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.10000_PLUS');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-16"
|
||||
:class="totalConversations > 100 ? 'max-w-4xl' : 'max-w-3xl'"
|
||||
>
|
||||
<div class="flex items-center justify-center flex-1">
|
||||
<div
|
||||
class="flex items-center justify-between gap-6 flex-1"
|
||||
:class="totalConversations > 100 ? 'md:gap-16' : 'md:gap-8'"
|
||||
>
|
||||
<div class="text-white flex gap-3 flex-col">
|
||||
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight">
|
||||
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.TITLE') }}
|
||||
</div>
|
||||
<div class="text-6xl md:text-8xl lg:text-[180px] tracking-tighter">
|
||||
{{ formatNumber(totalConversations) }}
|
||||
</div>
|
||||
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight -mt-2">
|
||||
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.SUBTITLE') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
:src="cloudImage"
|
||||
alt="Cloud"
|
||||
class="w-auto h-32 md:h-56 lg:h-80 -mr-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-3 md:gap-6">
|
||||
<img
|
||||
:src="doubleQuotesImage"
|
||||
alt="Quote"
|
||||
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
|
||||
/>
|
||||
<p
|
||||
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
|
||||
>
|
||||
{{ performanceHelperText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
year: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const candlesImagePath =
|
||||
'/assets/images/dashboard/year-in-review/first-frame-candles.png';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center text-black px-8 md:px-16 lg:px-24 py-10 md:py-16 lg:py-20 bg-cover bg-center min-h-[700px]"
|
||||
:style="{
|
||||
backgroundImage: `url('/assets/images/dashboard/year-in-review/first-frame-bg.png')`,
|
||||
}"
|
||||
>
|
||||
<div class="text-center max-w-3xl">
|
||||
<h1
|
||||
class="text-8xl md:text-9xl lg:text-[220px] font-semibold mb-4 md:mb-6 leading-none tracking-tight text-n-slate-12 dark:text-n-slate-1"
|
||||
>
|
||||
{{ year }}
|
||||
</h1>
|
||||
<h2
|
||||
class="text-3xl md:text-4xl lg:text-5xl font-medium mb-12 md:mb-16 lg:mb-20 text-n-slate-12 dark:text-n-slate-1"
|
||||
>
|
||||
{{ t('YEAR_IN_REVIEW.TITLE') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<img
|
||||
:src="candlesImagePath"
|
||||
alt="Candles"
|
||||
class="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-auto h-32 md:h-48 lg:h-64"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,99 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
supportPersonality: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const clockImage =
|
||||
'/assets/images/dashboard/year-in-review/fourth-frame-clock.png';
|
||||
const doubleQuotesImage =
|
||||
'/assets/images/dashboard/year-in-review/double-quotes.png';
|
||||
|
||||
const formatResponseTime = seconds => {
|
||||
if (seconds < 60) {
|
||||
return 'less than a minute';
|
||||
}
|
||||
if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
|
||||
}
|
||||
if (seconds < 86400) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
return hours === 1 ? '1 hour' : `${hours} hours`;
|
||||
}
|
||||
return 'more than a day';
|
||||
};
|
||||
|
||||
const personality = computed(() => {
|
||||
const seconds = props.supportPersonality.avg_response_time_seconds;
|
||||
const minutes = seconds / 60;
|
||||
|
||||
if (minutes < 2) {
|
||||
return 'Swift Helper';
|
||||
}
|
||||
if (minutes < 5) {
|
||||
return 'Quick Responder';
|
||||
}
|
||||
if (minutes < 15) {
|
||||
return 'Steady Support';
|
||||
}
|
||||
return 'Thoughtful Advisor';
|
||||
});
|
||||
|
||||
const personalityMessage = computed(() => {
|
||||
const seconds = props.supportPersonality.avg_response_time_seconds;
|
||||
const time = formatResponseTime(seconds);
|
||||
|
||||
const personalityKeyMap = {
|
||||
'Swift Helper': 'SWIFT_HELPER',
|
||||
'Quick Responder': 'QUICK_RESPONDER',
|
||||
'Steady Support': 'STEADY_SUPPORT',
|
||||
'Thoughtful Advisor': 'THOUGHTFUL_ADVISOR',
|
||||
};
|
||||
|
||||
const key = personalityKeyMap[personality.value];
|
||||
if (!key) return '';
|
||||
|
||||
return t(`YEAR_IN_REVIEW.PERSONALITY.MESSAGES.${key}`, { time });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
|
||||
<div class="flex flex-col gap-9 max-w-3xl">
|
||||
<div class="mb-4 md:mb-6">
|
||||
<img :src="clockImage" alt="Clock" class="w-auto h-28" />
|
||||
<div class="flex items-center justify-start flex-1 mt-9">
|
||||
<div class="text-n-slate-1 dark:text-n-slate-12 flex gap-3 flex-col">
|
||||
<div class="text-2xl md:text-4xl tracking-tight">
|
||||
{{ t('YEAR_IN_REVIEW.PERSONALITY.TITLE') }}
|
||||
</div>
|
||||
<div class="text-6xl md:text-7xl lg:text-8xl tracking-tighter">
|
||||
{{ personality }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center gap-3 md:gap-6">
|
||||
<img
|
||||
:src="doubleQuotesImage"
|
||||
alt="Quote"
|
||||
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
|
||||
/>
|
||||
<p
|
||||
class="text-xl md:text-3xl lg:text-3xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
|
||||
>
|
||||
{{ personalityMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
year: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const signatureImage =
|
||||
'/assets/images/dashboard/year-in-review/fifth-frame-signature.png';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
|
||||
>
|
||||
<div class="flex flex-col items-start max-w-4xl">
|
||||
<div
|
||||
class="text-3xl md:text-5xl lg:text-6xl font-bold tracking-tight !leading-tight text-n-slate-12 dark:text-n-slate-1"
|
||||
>
|
||||
{{ t('YEAR_IN_REVIEW.THANK_YOU.TITLE', { year }) }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xl lg:text-3xl mt-8 font-medium !leading-snug text-n-slate-12 dark:text-n-slate-1"
|
||||
>
|
||||
{{
|
||||
t('YEAR_IN_REVIEW.THANK_YOU.MESSAGE', { nextYear: Number(year) + 1 })
|
||||
}}
|
||||
</div>
|
||||
<div class="mt-12">
|
||||
<img
|
||||
:src="signatureImage"
|
||||
alt="Chatwoot Team Signature"
|
||||
class="w-auto h-8 md:h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -54,7 +54,6 @@ import {
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
calculateMenuPosition,
|
||||
getEffectiveChannelType,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
@@ -82,7 +81,6 @@ const props = defineProps({
|
||||
// are triggered except when this flag is true
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
medium: { type: String, default: '' },
|
||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||
focusOnMount: { type: Boolean, default: true },
|
||||
});
|
||||
@@ -107,16 +105,10 @@ const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(props.channelType, props.medium)
|
||||
);
|
||||
|
||||
const editorSchema = computed(() => {
|
||||
if (!props.channelType) return messageSchema;
|
||||
|
||||
const formatType = props.isPrivate
|
||||
? DEFAULT_FORMATTING
|
||||
: effectiveChannelType.value;
|
||||
const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType;
|
||||
const formatting = getFormattingForEditor(formatType);
|
||||
return buildMessageSchema(formatting.marks, formatting.nodes);
|
||||
});
|
||||
@@ -124,7 +116,7 @@ const editorSchema = computed(() => {
|
||||
const editorMenuOptions = computed(() => {
|
||||
const formatType = props.isPrivate
|
||||
? DEFAULT_FORMATTING
|
||||
: effectiveChannelType.value || DEFAULT_FORMATTING;
|
||||
: props.channelType || DEFAULT_FORMATTING;
|
||||
const formatting = getFormattingForEditor(formatType);
|
||||
return formatting.menu;
|
||||
});
|
||||
@@ -309,13 +301,8 @@ function isBodyEmpty(content) {
|
||||
|
||||
// if the signature is present, we need to remove it before checking
|
||||
// note that we don't update the editorView, so this is safe
|
||||
// Use effective channel type to match how signature was appended
|
||||
const bodyWithoutSignature = props.signature
|
||||
? removeSignatureHelper(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
)
|
||||
? removeSignatureHelper(content, props.signature)
|
||||
: content;
|
||||
|
||||
// trimming should remove all the whitespaces, so we can check the length
|
||||
@@ -383,11 +370,7 @@ function addSignature() {
|
||||
// see if the content is empty, if it is before appending the signature
|
||||
// we need to add a paragraph node and move the cursor at the start of the editor
|
||||
const contentWasEmpty = isBodyEmpty(content);
|
||||
content = appendSignature(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
content = appendSignature(content, props.signature);
|
||||
// need to reload first, ensuring that the editorView is updated
|
||||
reloadState(content);
|
||||
|
||||
@@ -399,11 +382,7 @@ function addSignature() {
|
||||
function removeSignature() {
|
||||
if (!props.signature) return;
|
||||
let content = props.modelValue;
|
||||
content = removeSignatureHelper(
|
||||
content,
|
||||
props.signature,
|
||||
effectiveChannelType.value
|
||||
);
|
||||
content = removeSignatureHelper(content, props.signature);
|
||||
// reload the state, ensuring that the editorView is updated
|
||||
reloadState(content);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ import { useTrack } from 'dashboard/composables';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import CannedResponse from './CannedResponse.vue';
|
||||
import ReplyToMessage from './ReplyToMessage.vue';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
|
||||
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
|
||||
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
|
||||
import ReplyEmailHead from './ReplyEmailHead.vue';
|
||||
@@ -45,8 +43,6 @@ import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
@@ -72,8 +68,6 @@ export default {
|
||||
WhatsappTemplates,
|
||||
WootMessageEditor,
|
||||
QuotedEmailPreview,
|
||||
ResizableTextArea,
|
||||
CannedResponse,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
@@ -114,8 +108,6 @@ export default {
|
||||
recordingAudioState: '',
|
||||
recordingAudioDurationText: '',
|
||||
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||
mentionSearchKey: '',
|
||||
hasSlashCommand: false,
|
||||
bccEmails: '',
|
||||
ccEmails: '',
|
||||
toEmails: '',
|
||||
@@ -144,12 +136,9 @@ export default {
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
currentContact() {
|
||||
const senderId = this.currentChat?.meta?.sender?.id;
|
||||
if (!senderId) return {};
|
||||
return this.$store.getters['contacts/getContact'](senderId);
|
||||
},
|
||||
isRichEditorEnabled() {
|
||||
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
|
||||
return this.$store.getters['contacts/getContact'](
|
||||
this.currentChat.meta.sender.id
|
||||
);
|
||||
},
|
||||
shouldShowReplyToMessage() {
|
||||
return (
|
||||
@@ -406,19 +395,6 @@ export default {
|
||||
!!this.quotedEmailText
|
||||
);
|
||||
},
|
||||
showRichContentEditor() {
|
||||
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
// ensure that the signature is plain text depending on `showRichContentEditor`
|
||||
signatureToApply() {
|
||||
return this.showRichContentEditor
|
||||
? this.messageSignature
|
||||
: extractTextFromMarkdown(this.messageSignature);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation, oldConversation) {
|
||||
@@ -461,25 +437,7 @@ export default {
|
||||
this.resetRecorderAndClearAttachments();
|
||||
}
|
||||
},
|
||||
message(updatedMessage) {
|
||||
// Check if the message starts with a slash.
|
||||
const bodyWithoutSignature = removeSignature(
|
||||
updatedMessage,
|
||||
this.signatureToApply
|
||||
);
|
||||
const startsWithSlash = bodyWithoutSignature.startsWith('/');
|
||||
|
||||
// Determine if the user is potentially typing a slash command.
|
||||
// This is true if the message starts with a slash and the rich content editor is not active.
|
||||
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
|
||||
this.showMentions = this.hasSlashCommand;
|
||||
|
||||
// If a slash command is active, extract the command text after the slash.
|
||||
// If not, reset the mentionSearchKey.
|
||||
this.mentionSearchKey = this.hasSlashCommand
|
||||
? bodyWithoutSignature.substring(1)
|
||||
: '';
|
||||
|
||||
message() {
|
||||
// Autosave the current message draft.
|
||||
this.doAutoSaveDraft();
|
||||
},
|
||||
@@ -529,20 +487,14 @@ export default {
|
||||
methods: {
|
||||
handleInsert(article) {
|
||||
const { url, title } = article;
|
||||
if (this.isRichEditorEnabled) {
|
||||
// Removing empty lines from the title
|
||||
const lines = title.split('\n');
|
||||
const nonEmptyLines = lines.filter(line => line.trim() !== '');
|
||||
const filteredMarkdown = nonEmptyLines.join(' ');
|
||||
emitter.emit(
|
||||
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
|
||||
`[${filteredMarkdown}](${url})`
|
||||
);
|
||||
} else {
|
||||
this.addIntoEditor(
|
||||
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
|
||||
);
|
||||
}
|
||||
// Removing empty lines from the title
|
||||
const lines = title.split('\n');
|
||||
const nonEmptyLines = lines.filter(line => line.trim() !== '');
|
||||
const filteredMarkdown = nonEmptyLines.join(' ');
|
||||
emitter.emit(
|
||||
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
|
||||
`[${filteredMarkdown}](${url})`
|
||||
);
|
||||
|
||||
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
|
||||
},
|
||||
@@ -611,26 +563,10 @@ export default {
|
||||
if (this.isPrivate) {
|
||||
return message;
|
||||
}
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
return this.sendWithSignature
|
||||
? appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
)
|
||||
: removeSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.signatureToApply)
|
||||
: removeSignature(message, this.signatureToApply);
|
||||
? appendSignature(message, this.messageSignature)
|
||||
: removeSignature(message, this.messageSignature);
|
||||
},
|
||||
removeFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
@@ -646,7 +582,6 @@ export default {
|
||||
Escape: {
|
||||
action: () => {
|
||||
this.hideEmojiPicker();
|
||||
this.hideMentions();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
@@ -689,9 +624,6 @@ export default {
|
||||
},
|
||||
onPaste(e) {
|
||||
const data = e.clipboardData.files;
|
||||
if (!this.showRichContentEditor && data.length !== 0) {
|
||||
this.$refs.messageInput?.$el?.blur();
|
||||
}
|
||||
if (!data.length || !data[0]) {
|
||||
return;
|
||||
}
|
||||
@@ -825,19 +757,7 @@ export default {
|
||||
// if signature is enabled, append it to the message
|
||||
// appendSignature ensures that the signature is not duplicated
|
||||
// so we don't need to check if the signature is already present
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
message = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
} else {
|
||||
message = appendSignature(message, this.signatureToApply);
|
||||
}
|
||||
message = appendSignature(message, this.messageSignature);
|
||||
}
|
||||
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
@@ -861,52 +781,22 @@ export default {
|
||||
});
|
||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
this.replyType = mode;
|
||||
if (this.showRichContentEditor) {
|
||||
if (this.isRecordingAudio) {
|
||||
this.toggleAudioRecorder();
|
||||
}
|
||||
return;
|
||||
if (this.isRecordingAudio) {
|
||||
this.toggleAudioRecorder();
|
||||
}
|
||||
this.$nextTick(() => this.$refs.messageInput.focus());
|
||||
},
|
||||
clearEditorSelection() {
|
||||
this.updateEditorSelectionWith = '';
|
||||
},
|
||||
insertIntoTextEditor(text, selectionStart, selectionEnd) {
|
||||
const { message } = this;
|
||||
const newMessage =
|
||||
message.slice(0, selectionStart) +
|
||||
text +
|
||||
message.slice(selectionEnd, message.length);
|
||||
this.message = newMessage;
|
||||
},
|
||||
addIntoEditor(content) {
|
||||
if (this.showRichContentEditor) {
|
||||
this.updateEditorSelectionWith = content;
|
||||
this.onFocus();
|
||||
}
|
||||
if (!this.showRichContentEditor) {
|
||||
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
|
||||
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
|
||||
}
|
||||
this.updateEditorSelectionWith = content;
|
||||
this.onFocus();
|
||||
},
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
// if signature is enabled, append it to the message
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
this.message = appendSignature(
|
||||
this.message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
} else {
|
||||
this.message = appendSignature(this.message, this.signatureToApply);
|
||||
}
|
||||
this.message = appendSignature(this.message, this.messageSignature);
|
||||
}
|
||||
this.attachedFiles = [];
|
||||
this.isRecordingAudio = false;
|
||||
@@ -941,9 +831,6 @@ export default {
|
||||
this.toggleEmojiPicker();
|
||||
}
|
||||
},
|
||||
hideMentions() {
|
||||
this.showMentions = false;
|
||||
},
|
||||
onTypingOn() {
|
||||
this.toggleTyping('on');
|
||||
},
|
||||
@@ -1190,13 +1077,6 @@ export default {
|
||||
:message="inReplyTo"
|
||||
@dismiss="resetReplyToMessage"
|
||||
/>
|
||||
<CannedResponse
|
||||
v-if="showMentions && hasSlashCommand"
|
||||
v-on-clickaway="hideMentions"
|
||||
class="normal-editor__canned-box"
|
||||
:search-key="mentionSearchKey"
|
||||
@replace="replaceText"
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
@@ -1220,23 +1100,7 @@ export default {
|
||||
@play="recordingAudioState = 'playing'"
|
||||
@pause="recordingAudioState = 'paused'"
|
||||
/>
|
||||
<ResizableTextArea
|
||||
v-else-if="!showRichContentEditor"
|
||||
ref="messageInput"
|
||||
v-model="message"
|
||||
class="rounded-none input"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:min-height="4"
|
||||
:signature="signatureToApply"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-else
|
||||
v-model="message"
|
||||
:editor-id="editorStateId"
|
||||
class="input popover-prosemirror-menu"
|
||||
@@ -1249,7 +1113,6 @@ export default {
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:channel-type="channelType"
|
||||
:medium="inbox.medium"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@@ -1362,10 +1225,6 @@ export default {
|
||||
|
||||
.reply-box__top {
|
||||
@apply relative py-0 px-4 -mt-px;
|
||||
|
||||
textarea {
|
||||
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
@@ -1385,9 +1244,4 @@ export default {
|
||||
@apply ltr:left-1 rtl:right-1 -bottom-2;
|
||||
}
|
||||
}
|
||||
|
||||
.normal-editor__canned-box {
|
||||
width: calc(100% - 2 * 1rem);
|
||||
left: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
import { getBusinessHoursConfig } from 'dashboard/helper/slaHelper';
|
||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -59,17 +58,9 @@ const groupClass = computed(() => {
|
||||
});
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
const businessHoursConfig = getBusinessHoursConfig(
|
||||
appliedSLA.value?.sla_policy,
|
||||
props.chat?.inbox
|
||||
);
|
||||
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value,
|
||||
chat: props.chat,
|
||||
options: businessHoursConfig
|
||||
? { businessHours: businessHoursConfig }
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export const FORMATTING = {
|
||||
// Channel formatting
|
||||
'Channel::Email': {
|
||||
marks: ['strong', 'em', 'code', 'link'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
||||
menu: [
|
||||
'strong',
|
||||
'em',
|
||||
@@ -19,7 +19,7 @@ export const FORMATTING = {
|
||||
},
|
||||
'Channel::WebWidget': {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
||||
menu: [
|
||||
'strong',
|
||||
'em',
|
||||
@@ -127,7 +127,7 @@ export const FORMATTING = {
|
||||
},
|
||||
'Context::MessageSignature': {
|
||||
marks: ['strong', 'em', 'link'],
|
||||
nodes: ['image'],
|
||||
nodes: [],
|
||||
menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
|
||||
},
|
||||
'Context::InboxSettings': {
|
||||
@@ -210,12 +210,7 @@ export const MARKDOWN_PATTERNS = [
|
||||
type: 'em', // PM: em, eg: *italic* or _italic_
|
||||
patterns: [
|
||||
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
|
||||
// Match _text_ only at word boundaries (whitespace/string start/end)
|
||||
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
|
||||
{
|
||||
pattern: /(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
|
||||
replacement: '$1',
|
||||
},
|
||||
{ pattern: /(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, replacement: '$1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,82 +6,8 @@ import {
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
* Links will be converted to text, and not removed.
|
||||
*
|
||||
* @param {string} markdown - markdown text to be extracted
|
||||
* @returns {string} - The extracted text.
|
||||
*/
|
||||
export function extractTextFromMarkdown(markdown) {
|
||||
if (!markdown) return '';
|
||||
return markdown
|
||||
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
|
||||
.replace(/`.*?`/g, '') // Remove inline code
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
|
||||
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n') // Trim each line & remove any lines only having spaces
|
||||
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
|
||||
.trim(); // Trim any extra space
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unsupported markdown formatting based on channel capabilities.
|
||||
*
|
||||
* @param {string} markdown - markdown text to process
|
||||
* @param {string} channelType - The channel type to check supported formatting
|
||||
* @returns {string} - The markdown with unsupported formatting removed
|
||||
*/
|
||||
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
|
||||
if (!markdown) return '';
|
||||
|
||||
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
|
||||
const has = (arr, key) => arr.includes(key);
|
||||
|
||||
// Define stripping rules: [condition, pattern, replacement]
|
||||
const rules = [
|
||||
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
|
||||
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
|
||||
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
|
||||
[!has(marks, 'code'), /`([^`]+)`/g, '$1'],
|
||||
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
|
||||
[!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
|
||||
[!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
|
||||
// Match _text_ only at word boundaries (whitespace/string start/end)
|
||||
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
|
||||
[
|
||||
!has(marks, 'em'),
|
||||
/(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
|
||||
'$1',
|
||||
],
|
||||
[!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
|
||||
[!has(nodes, 'blockquote'), /^>\s?/gm, ''],
|
||||
[!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
|
||||
[!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
|
||||
];
|
||||
|
||||
const result = rules.reduce(
|
||||
(text, [shouldStrip, pattern, replacement]) =>
|
||||
shouldStrip ? text.replace(pattern, replacement) : text,
|
||||
markdown
|
||||
);
|
||||
|
||||
return result
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
.replace(/\n{2,}/g, '\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The delimiter used to separate the signature from the rest of the body.
|
||||
* @type {string}
|
||||
@@ -143,39 +69,15 @@ export function findSignatureInBody(body, signature) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the effective channel type for formatting purposes.
|
||||
* For Twilio channels, returns WhatsApp or Twilio based on medium.
|
||||
*
|
||||
* @param {string} channelType - The channel type
|
||||
* @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp)
|
||||
* @returns {string} - The effective channel type for formatting
|
||||
*/
|
||||
export function getEffectiveChannelType(channelType, medium) {
|
||||
if (channelType === INBOX_TYPES.TWILIO) {
|
||||
return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
? INBOX_TYPES.WHATSAPP
|
||||
: INBOX_TYPES.TWILIO;
|
||||
}
|
||||
return channelType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the signature to the body, separated by the signature delimiter.
|
||||
* Automatically strips unsupported formatting based on channel capabilities.
|
||||
*
|
||||
* @param {string} body - The body to append the signature to.
|
||||
* @param {string} signature - The signature to append.
|
||||
* @param {string} channelType - Optional. The effective channel type to determine supported formatting.
|
||||
* For Twilio channels, pass the result of getEffectiveChannelType().
|
||||
* @returns {string} - The body with the signature appended.
|
||||
*/
|
||||
export function appendSignature(body, signature, channelType) {
|
||||
// Strip only unsupported formatting based on channel capabilities
|
||||
const preparedSignature = channelType
|
||||
? stripUnsupportedSignatureMarkdown(signature, channelType)
|
||||
: signature;
|
||||
const cleanedSignature = cleanSignature(preparedSignature);
|
||||
export function appendSignature(body, signature) {
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
// if signature is already present, return body
|
||||
if (findSignatureInBody(body, cleanedSignature) > -1) {
|
||||
return body;
|
||||
@@ -186,34 +88,16 @@ export function appendSignature(body, signature, channelType) {
|
||||
|
||||
/**
|
||||
* Removes the signature from the body, along with the signature delimiter.
|
||||
* Tries to find both the original signature and the stripped version.
|
||||
*
|
||||
* @param {string} body - The body to remove the signature from.
|
||||
* @param {string} signature - The signature to remove.
|
||||
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
|
||||
* For Twilio channels, pass the result of getEffectiveChannelType().
|
||||
* @returns {string} - The body with the signature removed.
|
||||
*/
|
||||
export function removeSignature(body, signature, channelType) {
|
||||
// Build list of signatures to try: original, channel-stripped, and fully stripped
|
||||
export function removeSignature(body, signature) {
|
||||
// this will find the index of the signature if it exists
|
||||
// Regardless of extra spaces or new lines after the signature, the index will be the same if present
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
const channelStripped = channelType
|
||||
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
|
||||
: null;
|
||||
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
|
||||
|
||||
// Try signatures in order: original → channel-specific → fully stripped
|
||||
const signaturesToTry = [
|
||||
cleanedSignature,
|
||||
channelStripped,
|
||||
fullyStripped,
|
||||
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
|
||||
|
||||
// Find the first matching signature
|
||||
const signatureIndex = signaturesToTry.reduce(
|
||||
(index, sig) => (index === -1 ? findSignatureInBody(body, sig) : index),
|
||||
-1
|
||||
);
|
||||
const signatureIndex = findSignatureInBody(body, cleanedSignature);
|
||||
|
||||
// no need to trim the ends here, because it will simply be removed in the next method
|
||||
let newBody = body;
|
||||
@@ -254,6 +138,28 @@ export function replaceSignature(body, oldSignature, newSignature) {
|
||||
return appendSignature(withoutSignature, newSignature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
* Links will be converted to text, and not removed.
|
||||
*
|
||||
* @param {string} markdown - markdown text to be extracted
|
||||
* @returns
|
||||
*/
|
||||
export function extractTextFromMarkdown(markdown) {
|
||||
return markdown
|
||||
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
|
||||
.replace(/`.*?`/g, '') // Remove inline code
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
|
||||
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n') // Trim each line & remove any lines only having spaces
|
||||
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
|
||||
.trim(); // Trim any extra space
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the editor view into current cursor position
|
||||
*
|
||||
|
||||
@@ -13,11 +13,6 @@ export const INBOX_TYPES = {
|
||||
VOICE: 'Channel::Voice',
|
||||
};
|
||||
|
||||
export const TWILIO_CHANNEL_MEDIUM = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
SMS: 'sms',
|
||||
};
|
||||
|
||||
const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
||||
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Helper functions for SLA business hours configuration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts business hours configuration from SLA policy and inbox data
|
||||
* Supports both camelCase and snake_case property naming conventions
|
||||
*
|
||||
* @param {Object} slaPolicy - The SLA policy object
|
||||
* @param {Object} inbox - The inbox object with working hours configuration
|
||||
* @returns {Object|null} Business hours configuration for utils package, or null if not applicable
|
||||
*/
|
||||
export const getBusinessHoursConfig = (slaPolicy, inbox) => {
|
||||
// Handle both camelCase and snake_case property names
|
||||
const onlyDuringBusinessHours =
|
||||
slaPolicy?.only_during_business_hours ?? slaPolicy?.onlyDuringBusinessHours;
|
||||
const workingHoursEnabled =
|
||||
inbox?.working_hours_enabled ?? inbox?.workingHoursEnabled;
|
||||
|
||||
if (!onlyDuringBusinessHours || !workingHoursEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert working hours format to match utils expectation
|
||||
const workingHours = {};
|
||||
const dayMap = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||
|
||||
// Handle both camelCase and snake_case working hours arrays
|
||||
const inboxWorkingHours = inbox.working_hours ?? inbox.workingHours;
|
||||
|
||||
if (inboxWorkingHours) {
|
||||
inboxWorkingHours.forEach(dayConfig => {
|
||||
// Handle both property naming conventions
|
||||
const dayOfWeek = dayConfig.day_of_week ?? dayConfig.dayOfWeek;
|
||||
const closedAllDay = dayConfig.closed_all_day ?? dayConfig.closedAllDay;
|
||||
const openHour = dayConfig.open_hour ?? dayConfig.openHour;
|
||||
const openMinutes = dayConfig.open_minutes ?? dayConfig.openMinutes;
|
||||
const closeHour = dayConfig.close_hour ?? dayConfig.closeHour;
|
||||
const closeMinutes = dayConfig.close_minutes ?? dayConfig.closeMinutes;
|
||||
|
||||
const dayName = dayMap[dayOfWeek];
|
||||
|
||||
if (closedAllDay) {
|
||||
workingHours[dayName] = null;
|
||||
} else {
|
||||
// Convert to HH:MM format
|
||||
const startHour = String(openHour || 0).padStart(2, '0');
|
||||
const startMin = String(openMinutes || 0).padStart(2, '0');
|
||||
const endHour = String(closeHour || 0).padStart(2, '0');
|
||||
const endMin = String(closeMinutes || 0).padStart(2, '0');
|
||||
|
||||
workingHours[dayName] = {
|
||||
start: `${startHour}:${startMin}`,
|
||||
finish: `${endHour}:${endMin}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
working_hours_enabled: workingHoursEnabled,
|
||||
timezone: inbox.timezone || 'UTC',
|
||||
working_hours: workingHours,
|
||||
only_during_business_hours: onlyDuringBusinessHours,
|
||||
};
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
replaceSignature,
|
||||
cleanSignature,
|
||||
extractTextFromMarkdown,
|
||||
stripUnsupportedSignatureMarkdown,
|
||||
insertAtCursor,
|
||||
findNodeToInsertImage,
|
||||
setURLWithQueryAndSize,
|
||||
@@ -145,107 +144,6 @@ describe('appendSignature', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUnsupportedSignatureMarkdown', () => {
|
||||
const richSignature =
|
||||
'**Bold** _italic_ [link](http://example.com) ';
|
||||
|
||||
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Email'
|
||||
);
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('[link](http://example.com)');
|
||||
expect(result).toContain('');
|
||||
});
|
||||
it('strips images but keeps bold/italic for Api channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Api'
|
||||
);
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('link'); // link text kept
|
||||
expect(result).not.toContain('[link]('); // link syntax removed
|
||||
expect(result).not.toContain('; // image removed
|
||||
});
|
||||
it('strips images but keeps bold/italic/link for Telegram channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Telegram'
|
||||
);
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('[link](http://example.com)');
|
||||
expect(result).not.toContain(';
|
||||
});
|
||||
it('strips all formatting for SMS channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Sms'
|
||||
);
|
||||
expect(result).toContain('Bold');
|
||||
expect(result).toContain('italic');
|
||||
expect(result).toContain('link');
|
||||
expect(result).not.toContain('**');
|
||||
expect(result).not.toContain('_');
|
||||
expect(result).not.toContain('[');
|
||||
expect(result).not.toContain(';
|
||||
});
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
|
||||
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendSignature with channelType', () => {
|
||||
const signatureWithImage =
|
||||
'Thanks\n';
|
||||
|
||||
it('keeps images for Email channel', () => {
|
||||
const result = appendSignature(
|
||||
'Hello',
|
||||
signatureWithImage,
|
||||
'Channel::Email'
|
||||
);
|
||||
expect(result).toContain(';
|
||||
});
|
||||
it('keeps images for WebWidget channel', () => {
|
||||
const result = appendSignature(
|
||||
'Hello',
|
||||
signatureWithImage,
|
||||
'Channel::WebWidget'
|
||||
);
|
||||
expect(result).toContain(';
|
||||
});
|
||||
it('strips images but keeps text for Api channel', () => {
|
||||
const result = appendSignature('Hello', signatureWithImage, 'Channel::Api');
|
||||
expect(result).not.toContain(';
|
||||
expect(result).toContain('Thanks');
|
||||
});
|
||||
it('strips images but keeps text for WhatsApp channel', () => {
|
||||
const result = appendSignature(
|
||||
'Hello',
|
||||
signatureWithImage,
|
||||
'Channel::Whatsapp'
|
||||
);
|
||||
expect(result).not.toContain(';
|
||||
expect(result).toContain('Thanks');
|
||||
});
|
||||
it('keeps images when channelType is not provided', () => {
|
||||
const result = appendSignature('Hello', signatureWithImage);
|
||||
expect(result).toContain(';
|
||||
});
|
||||
it('keeps bold/italic for channels that support them', () => {
|
||||
const boldSignature = '**Bold** *italic* Thanks';
|
||||
const result = appendSignature('Hello', boldSignature, 'Channel::Api');
|
||||
// Api supports strong and em
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('*italic*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanSignature', () => {
|
||||
it('removes any instance of horizontal rule', () => {
|
||||
const options = [
|
||||
@@ -304,37 +202,6 @@ describe('removeSignature', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeSignature with stripped signature', () => {
|
||||
const signatureWithImage =
|
||||
'Thanks\n';
|
||||
|
||||
it('removes stripped signature from body', () => {
|
||||
// Simulate a body where signature was added with images stripped
|
||||
const bodyWithStrippedSignature = 'Hello\n\n--\n\nThanks';
|
||||
const result = removeSignature(
|
||||
bodyWithStrippedSignature,
|
||||
signatureWithImage
|
||||
);
|
||||
expect(result).toBe('Hello\n\n');
|
||||
});
|
||||
it('removes original signature from body', () => {
|
||||
// Simulate a body where signature was added with images (using cleanSignature format)
|
||||
const cleanedSig = cleanSignature(signatureWithImage);
|
||||
const bodyWithOriginalSignature = `Hello\n\n--\n\n${cleanedSig}`;
|
||||
const result = removeSignature(
|
||||
bodyWithOriginalSignature,
|
||||
signatureWithImage
|
||||
);
|
||||
expect(result).toBe('Hello\n\n');
|
||||
});
|
||||
it('handles signature without images', () => {
|
||||
const simpleSignature = 'Best regards';
|
||||
const body = 'Hello\n\n--\n\nBest regards';
|
||||
const result = removeSignature(body, simpleSignature);
|
||||
expect(result).toBe('Hello\n\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceSignature', () => {
|
||||
it('appends the new signature if not present', () => {
|
||||
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
||||
@@ -926,26 +793,6 @@ describe('stripUnsupportedFormatting', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves underscores in URLs and mid-word positions', () => {
|
||||
// Underscores in URLs should not be stripped as italic formatting
|
||||
expect(
|
||||
stripUnsupportedFormatting(
|
||||
'https://www.chatwoot.com/new_first_second-third/ssd',
|
||||
emptySchema
|
||||
)
|
||||
).toBe('https://www.chatwoot.com/new_first_second-third/ssd');
|
||||
|
||||
// Underscores in variable names should not be stripped
|
||||
expect(
|
||||
stripUnsupportedFormatting('some_variable_name', emptySchema)
|
||||
).toBe('some_variable_name');
|
||||
|
||||
// But actual italic formatting with spaces should still be stripped
|
||||
expect(
|
||||
stripUnsupportedFormatting('hello _world_ there', emptySchema)
|
||||
).toBe('hello world there');
|
||||
});
|
||||
|
||||
it('strips inline code formatting', () => {
|
||||
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
|
||||
'inline code'
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
import { getBusinessHoursConfig } from '../slaHelper';
|
||||
|
||||
describe('slaHelper', () => {
|
||||
describe('getBusinessHoursConfig', () => {
|
||||
const mockWorkingHours = [
|
||||
{
|
||||
day_of_week: 1, // Monday
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 30,
|
||||
closed_all_day: false,
|
||||
},
|
||||
{
|
||||
day_of_week: 2, // Tuesday
|
||||
open_hour: 8,
|
||||
open_minutes: 30,
|
||||
close_hour: 18,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
},
|
||||
{
|
||||
day_of_week: 0, // Sunday
|
||||
closed_all_day: true,
|
||||
},
|
||||
];
|
||||
|
||||
const mockWorkingHoursCamelCase = [
|
||||
{
|
||||
dayOfWeek: 1, // Monday
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 30,
|
||||
closedAllDay: false,
|
||||
},
|
||||
{
|
||||
dayOfWeek: 2, // Tuesday
|
||||
openHour: 8,
|
||||
openMinutes: 30,
|
||||
closeHour: 18,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
},
|
||||
{
|
||||
dayOfWeek: 0, // Sunday
|
||||
closedAllDay: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe('when business hours are not required', () => {
|
||||
it('returns null when SLA policy does not require business hours', () => {
|
||||
const slaPolicy = { only_during_business_hours: false };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: mockWorkingHours,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when inbox does not have working hours enabled', () => {
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: false,
|
||||
working_hours: mockWorkingHours,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when both conditions are false', () => {
|
||||
const slaPolicy = { only_during_business_hours: false };
|
||||
const inbox = { working_hours_enabled: false };
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when slaPolicy is null', () => {
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: mockWorkingHours,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(null, inbox);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when inbox is null', () => {
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, null);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when business hours are required - snake_case properties', () => {
|
||||
it('converts working hours correctly with snake_case properties', () => {
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: mockWorkingHours,
|
||||
timezone: 'America/New_York',
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result).toEqual({
|
||||
working_hours_enabled: true,
|
||||
timezone: 'America/New_York',
|
||||
working_hours: {
|
||||
sun: null, // Closed all day
|
||||
mon: {
|
||||
start: '09:00',
|
||||
finish: '17:30',
|
||||
},
|
||||
tue: {
|
||||
start: '08:30',
|
||||
finish: '18:00',
|
||||
},
|
||||
},
|
||||
only_during_business_hours: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing timezone by defaulting to UTC', () => {
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: mockWorkingHours.slice(0, 1), // Just Monday
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.timezone).toBe('UTC');
|
||||
});
|
||||
|
||||
it('handles zero hours and minutes correctly', () => {
|
||||
const workingHoursWithZeros = [
|
||||
{
|
||||
day_of_week: 1,
|
||||
open_hour: 0,
|
||||
open_minutes: 0,
|
||||
close_hour: 0,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
},
|
||||
];
|
||||
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: workingHoursWithZeros,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.working_hours.mon).toEqual({
|
||||
start: '00:00',
|
||||
finish: '00:00',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when business hours are required - camelCase properties', () => {
|
||||
it('converts working hours correctly with camelCase properties', () => {
|
||||
const slaPolicy = { onlyDuringBusinessHours: true };
|
||||
const inbox = {
|
||||
workingHoursEnabled: true,
|
||||
workingHours: mockWorkingHoursCamelCase,
|
||||
timezone: 'America/Los_Angeles',
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result).toEqual({
|
||||
working_hours_enabled: true,
|
||||
timezone: 'America/Los_Angeles',
|
||||
working_hours: {
|
||||
sun: null, // Closed all day
|
||||
mon: {
|
||||
start: '09:00',
|
||||
finish: '17:30',
|
||||
},
|
||||
tue: {
|
||||
start: '08:30',
|
||||
finish: '18:00',
|
||||
},
|
||||
},
|
||||
only_during_business_hours: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed property naming conventions', () => {
|
||||
it('handles mixed snake_case and camelCase properties', () => {
|
||||
const slaPolicy = {
|
||||
only_during_business_hours: true, // snake_case
|
||||
onlyDuringBusinessHours: false, // This should be ignored due to nullish coalescing
|
||||
};
|
||||
const inbox = {
|
||||
working_hours_enabled: true, // snake_case
|
||||
workingHours: mockWorkingHoursCamelCase, // camelCase
|
||||
timezone: 'UTC',
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result.only_during_business_hours).toBe(true);
|
||||
expect(result.working_hours_enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('prioritizes snake_case over camelCase when both exist', () => {
|
||||
const slaPolicy = {
|
||||
only_during_business_hours: true,
|
||||
onlyDuringBusinessHours: false,
|
||||
};
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
workingHoursEnabled: false,
|
||||
working_hours: mockWorkingHours,
|
||||
workingHours: mockWorkingHoursCamelCase,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.only_during_business_hours).toBe(true);
|
||||
expect(result.working_hours_enabled).toBe(true);
|
||||
// Should use snake_case working_hours
|
||||
expect(result.working_hours.mon.start).toBe('09:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty working hours array', () => {
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: [],
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.working_hours).toEqual({});
|
||||
});
|
||||
|
||||
it('handles missing working hours property', () => {
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
// No working_hours property
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.working_hours).toEqual({});
|
||||
});
|
||||
|
||||
it('handles undefined values in working hours', () => {
|
||||
const workingHoursWithUndefined = [
|
||||
{
|
||||
day_of_week: 1,
|
||||
open_hour: undefined,
|
||||
open_minutes: undefined,
|
||||
close_hour: undefined,
|
||||
close_minutes: undefined,
|
||||
closed_all_day: false,
|
||||
},
|
||||
];
|
||||
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: workingHoursWithUndefined,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.working_hours.mon).toEqual({
|
||||
start: '00:00',
|
||||
finish: '00:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('pads single digit hours and minutes correctly', () => {
|
||||
const workingHoursSingleDigit = [
|
||||
{
|
||||
day_of_week: 3, // Wednesday
|
||||
open_hour: 9,
|
||||
open_minutes: 5,
|
||||
close_hour: 5,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
},
|
||||
];
|
||||
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: workingHoursSingleDigit,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.working_hours.wed).toEqual({
|
||||
start: '09:05',
|
||||
finish: '05:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles all days of the week correctly', () => {
|
||||
const fullWeekWorkingHours = [
|
||||
{ day_of_week: 0, closed_all_day: true }, // Sunday
|
||||
{
|
||||
day_of_week: 1,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
}, // Monday
|
||||
{
|
||||
day_of_week: 2,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
}, // Tuesday
|
||||
{
|
||||
day_of_week: 3,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
}, // Wednesday
|
||||
{
|
||||
day_of_week: 4,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
}, // Thursday
|
||||
{
|
||||
day_of_week: 5,
|
||||
open_hour: 9,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
close_minutes: 0,
|
||||
closed_all_day: false,
|
||||
}, // Friday
|
||||
{ day_of_week: 6, closed_all_day: true }, // Saturday
|
||||
];
|
||||
|
||||
const slaPolicy = { only_during_business_hours: true };
|
||||
const inbox = {
|
||||
working_hours_enabled: true,
|
||||
working_hours: fullWeekWorkingHours,
|
||||
};
|
||||
|
||||
const result = getBusinessHoursConfig(slaPolicy, inbox);
|
||||
|
||||
expect(result.working_hours).toEqual({
|
||||
sun: null,
|
||||
mon: { start: '09:00', finish: '17:00' },
|
||||
tue: { start: '09:00', finish: '17:00' },
|
||||
wed: { start: '09:00', finish: '17:00' },
|
||||
thu: { start: '09:00', finish: '17:00' },
|
||||
fri: { start: '09:00', finish: '17:00' },
|
||||
sat: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,6 @@ import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
import yearInReview from './yearInReview.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
@@ -81,5 +80,4 @@ export default {
|
||||
...whatsappTemplates,
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
...yearInReview,
|
||||
};
|
||||
|
||||
@@ -234,7 +234,6 @@
|
||||
"CONTACT_SUPPORT": "Contact support",
|
||||
"SELECTOR_SUBTITLE": "Select an account from the following list",
|
||||
"PROFILE_SETTINGS": "Profile settings",
|
||||
"YEAR_IN_REVIEW": "Year in Review",
|
||||
"KEYBOARD_SHORTCUTS": "Keyboard shortcuts",
|
||||
"APPEARANCE": "Change appearance",
|
||||
"SUPER_ADMIN_CONSOLE": "SuperAdmin console",
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"YEAR_IN_REVIEW": {
|
||||
"TITLE": "Year in Review",
|
||||
"LOADING": "Loading your year in review...",
|
||||
"ERROR": "Failed to load year in review",
|
||||
"CLOSE": "Close",
|
||||
"CONVERSATIONS": {
|
||||
"TITLE": "You have handled",
|
||||
"SUBTITLE": "conversations",
|
||||
"FALLBACK": "This year wasn't about the numbers. It was about showing up.",
|
||||
"COMPARISON": {
|
||||
"0_50": "You showed up, and that's how every good inbox begins.",
|
||||
"50_100": "You kept the replies flowing and the conversations alive.",
|
||||
"100_500": "You handled serious volume and kept everything on track.",
|
||||
"500_2000": "You kept things moving while the volume kept climbing.",
|
||||
"2000_10000": "You ran high traffic through your inbox without breaking a sweat.",
|
||||
"10000_PLUS": "That's a full city of customers knocking on your door. You made it look effortless."
|
||||
}
|
||||
},
|
||||
"BUSIEST_DAY": {
|
||||
"TITLE": "Your busiest day was",
|
||||
"MESSAGE": "{count} conversations that day.",
|
||||
"COMPARISON": {
|
||||
"0_5": "A warm-up lap that barely woke the inbox.",
|
||||
"5_10": "Enough action to justify a second cup of coffee.",
|
||||
"10_25": "Things got busy and the inbox stayed on its toes.",
|
||||
"25_50": "A proper rush that barely broke a sweat.",
|
||||
"50_100": "Controlled chaos, handled like a normal Tuesday.",
|
||||
"100_500": "Absolute dumpster fire, somehow still shipping replies.",
|
||||
"500_PLUS": "The inbox lost all chill and never slowed down."
|
||||
}
|
||||
},
|
||||
"PERSONALITY": {
|
||||
"TITLE": "Your support personality is",
|
||||
"MESSAGES": {
|
||||
"SWIFT_HELPER": "You replied in {time} on average. Faster than most notifications.",
|
||||
"QUICK_RESPONDER": "You replied in {time} on average. The inbox barely waited.",
|
||||
"STEADY_SUPPORT": "You replied in {time} on average. Calm pace, solid replies.",
|
||||
"THOUGHTFUL_ADVISOR": "You replied in {time} on average. Took the time to get it right."
|
||||
}
|
||||
},
|
||||
"THANK_YOU": {
|
||||
"TITLE": "Congratulations on surviving the inbox of {year}.",
|
||||
"MESSAGE": "Thank you for your incredible dedication to supporting customers throughout this year. Your hard work has made a real difference, and we're grateful to have you on this journey. Here's to making {nextYear} even better together!"
|
||||
},
|
||||
"SHARE_MODAL": {
|
||||
"TITLE": "Share Your Year in Review",
|
||||
"PREPARING": "Preparing your image...",
|
||||
"DOWNLOAD": "Download",
|
||||
"SHARE_TITLE": "My {year} Year in Review",
|
||||
"SHARE_TEXT": "Check out my {year} Year in Review with Chatwoot!",
|
||||
"BRANDING": "Made with Chatwoot"
|
||||
},
|
||||
"BANNER": {
|
||||
"TITLE": "Your {year} Year in Review is here",
|
||||
"BUTTON": "See your impact"
|
||||
},
|
||||
"NAVIGATION": {
|
||||
"PREVIOUS": "Previous",
|
||||
"NEXT": "Next",
|
||||
"SHARE": "Share"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,15 +111,10 @@ export default {
|
||||
// watcher, this means that if the value is true, the signature
|
||||
// is supposed to be added, else we remove it.
|
||||
toggleSignatureInEditor(signatureEnabled) {
|
||||
let valueWithSignature = signatureEnabled
|
||||
const valueWithSignature = signatureEnabled
|
||||
? appendSignature(this.modelValue, this.cleanedSignature)
|
||||
: removeSignature(this.modelValue, this.cleanedSignature);
|
||||
|
||||
// Clean up whitespace when removing signature from empty body
|
||||
if (!signatureEnabled && !valueWithSignature.trim()) {
|
||||
valueWithSignature = '';
|
||||
}
|
||||
|
||||
this.$emit('update:modelValue', valueWithSignature);
|
||||
this.$emit('input', valueWithSignature);
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@ class DeleteObjectJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
BATCH_SIZE = 5_000
|
||||
HEAVY_ASSOCIATIONS = {
|
||||
Account => %i[conversations contacts inboxes reporting_events],
|
||||
Inbox => %i[conversations contact_inboxes reporting_events]
|
||||
}.freeze
|
||||
|
||||
def perform(object, user = nil, ip = nil)
|
||||
# Pre-purge heavy associations for large objects to avoid
|
||||
@@ -15,18 +19,11 @@ class DeleteObjectJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def heavy_associations
|
||||
{
|
||||
Account => %i[conversations contacts inboxes reporting_events],
|
||||
Inbox => %i[conversations contact_inboxes reporting_events]
|
||||
}.freeze
|
||||
end
|
||||
|
||||
def purge_heavy_associations(object)
|
||||
klass = heavy_associations.keys.find { |k| object.is_a?(k) }
|
||||
klass = HEAVY_ASSOCIATIONS.keys.find { |k| object.is_a?(k) }
|
||||
return unless klass
|
||||
|
||||
heavy_associations[klass].each do |assoc|
|
||||
HEAVY_ASSOCIATIONS[klass].each do |assoc|
|
||||
next unless object.respond_to?(assoc)
|
||||
|
||||
batch_destroy(object.public_send(assoc))
|
||||
|
||||
@@ -254,21 +254,6 @@ class Message < ApplicationRecord
|
||||
Messages::SearchDataPresenter.new(self).search_data
|
||||
end
|
||||
|
||||
# Returns message content suitable for LLM consumption
|
||||
# Falls back to audio transcription or attachment placeholder when content is nil
|
||||
def content_for_llm
|
||||
return content if content.present?
|
||||
|
||||
audio_transcription = attachments
|
||||
.where(file_type: :audio)
|
||||
.filter_map { |att| att.meta&.dig('transcribed_text') }
|
||||
.join(' ')
|
||||
.presence
|
||||
return "[Voice Message] #{audio_transcription}" if audio_transcription.present?
|
||||
|
||||
'[Attachment]' if attachments.any?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prevent_message_flooding
|
||||
|
||||
@@ -48,7 +48,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
||||
'Bot'
|
||||
end
|
||||
sender = "[Private Note] #{sender}" if message.private?
|
||||
"#{sender}: #{message.content_for_llm}\n"
|
||||
"#{sender}: #{message.content}\n"
|
||||
end
|
||||
|
||||
def build_attributes
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
class MessageTemplates::Template::OutOfOffice
|
||||
pattr_initialize [:conversation!]
|
||||
|
||||
def self.perform_if_applicable(conversation)
|
||||
inbox = conversation.inbox
|
||||
return unless inbox.out_of_office?
|
||||
return if inbox.out_of_office_message.blank?
|
||||
|
||||
new(conversation: conversation).perform
|
||||
end
|
||||
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
conversation.messages.create!(out_of_office_message_params)
|
||||
|
||||
@@ -47,19 +47,13 @@ class Messages::MarkdownRendererService
|
||||
end
|
||||
|
||||
def render_telegram_html
|
||||
# Strip whitespace from whitespace-only lines to normalize newlines
|
||||
normalized_content = @content.gsub(/^[ \t]+$/m, '')
|
||||
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
|
||||
renderer = Messages::MarkdownRenderers::TelegramRenderer.new
|
||||
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:STRIKETHROUGH_DOUBLE_TILDE], [:strikethrough])
|
||||
result = renderer.render(doc).gsub(/\n+\z/, '')
|
||||
restore_multiple_newlines(result)
|
||||
doc = CommonMarker.render_doc(@content, [:STRIKETHROUGH_DOUBLE_TILDE], [:strikethrough])
|
||||
renderer.render(doc).gsub(/\n+\z/, '')
|
||||
end
|
||||
|
||||
def render_whatsapp
|
||||
# Strip whitespace from whitespace-only lines to normalize newlines
|
||||
normalized_content = @content.gsub(/^[ \t]+$/m, '')
|
||||
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
|
||||
content_with_preserved_newlines = preserve_multiple_newlines(@content)
|
||||
renderer = Messages::MarkdownRenderers::WhatsAppRenderer.new
|
||||
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
|
||||
result = renderer.render(doc).gsub(/\n+\z/, '')
|
||||
@@ -67,9 +61,7 @@ class Messages::MarkdownRendererService
|
||||
end
|
||||
|
||||
def render_instagram
|
||||
# Strip whitespace from whitespace-only lines to normalize newlines
|
||||
normalized_content = @content.gsub(/^[ \t]+$/m, '')
|
||||
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
|
||||
content_with_preserved_newlines = preserve_multiple_newlines(@content)
|
||||
renderer = Messages::MarkdownRenderers::InstagramRenderer.new
|
||||
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
|
||||
result = renderer.render(doc).gsub(/\n+\z/, '')
|
||||
@@ -77,9 +69,7 @@ class Messages::MarkdownRendererService
|
||||
end
|
||||
|
||||
def render_line
|
||||
# Strip whitespace from whitespace-only lines to normalize newlines
|
||||
normalized_content = @content.gsub(/^[ \t]+$/m, '')
|
||||
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
|
||||
content_with_preserved_newlines = preserve_multiple_newlines(@content)
|
||||
renderer = Messages::MarkdownRenderers::LineRenderer.new
|
||||
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
|
||||
result = renderer.render(doc).gsub(/\n+\z/, '')
|
||||
@@ -87,19 +77,17 @@ class Messages::MarkdownRendererService
|
||||
end
|
||||
|
||||
def render_plain_text
|
||||
# Strip whitespace from whitespace-only lines to normalize newlines
|
||||
normalized_content = @content.gsub(/^[ \t]+$/m, '')
|
||||
content_with_preserved_newlines = preserve_multiple_newlines(normalized_content)
|
||||
content_with_preserved_newlines = preserve_multiple_newlines(@content)
|
||||
renderer = Messages::MarkdownRenderers::PlainTextRenderer.new
|
||||
doc = CommonMarker.render_doc(content_with_preserved_newlines, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
|
||||
result = renderer.render(doc).gsub(/\n+\z/, '')
|
||||
restore_multiple_newlines(result)
|
||||
end
|
||||
|
||||
# Preserve multiple consecutive newlines (2+) by replacing them with placeholders
|
||||
# Standard markdown treats 2 newlines as paragraph break which collapses to 1 newline, we preserve 2+
|
||||
# Preserve multiple consecutive newlines (3+) by replacing them with placeholders
|
||||
# Standard markdown treats 2 newlines as paragraph break, we preserve 3+
|
||||
def preserve_multiple_newlines(content)
|
||||
content.gsub(/\n{2,}/) do |match|
|
||||
content.gsub(/\n{3,}/) do |match|
|
||||
"{{PRESERVE_#{match.length}_NEWLINES}}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
class Whatsapp::CsatTemplateNameService
|
||||
CSAT_BASE_NAME = 'customer_satisfaction_survey'.freeze
|
||||
|
||||
# Generates template names like: customer_satisfaction_survey_{inbox_id}_{version_number}
|
||||
|
||||
def self.csat_template_name(inbox_id, version = nil)
|
||||
base_name = csat_base_name_for_inbox(inbox_id)
|
||||
version ? "#{base_name}_#{version}" : base_name
|
||||
end
|
||||
|
||||
def self.extract_version(template_name, inbox_id)
|
||||
return nil if template_name.blank?
|
||||
|
||||
pattern = versioned_pattern_for_inbox(inbox_id)
|
||||
match = template_name.match(pattern)
|
||||
match ? match[1].to_i : nil
|
||||
end
|
||||
|
||||
def self.generate_next_template_name(base_name, inbox_id, current_template_name)
|
||||
return base_name if current_template_name.blank?
|
||||
|
||||
current_version = extract_version(current_template_name, inbox_id)
|
||||
next_version = current_version ? current_version + 1 : 1
|
||||
csat_template_name(inbox_id, next_version)
|
||||
end
|
||||
|
||||
def self.matches_csat_pattern?(template_name, inbox_id)
|
||||
return false if template_name.blank?
|
||||
|
||||
base_pattern = base_pattern_for_inbox(inbox_id)
|
||||
versioned_pattern = versioned_pattern_for_inbox(inbox_id)
|
||||
|
||||
template_name.match?(base_pattern) || template_name.match?(versioned_pattern)
|
||||
end
|
||||
|
||||
def self.csat_base_name_for_inbox(inbox_id)
|
||||
"#{CSAT_BASE_NAME}_#{inbox_id}"
|
||||
end
|
||||
|
||||
def self.base_pattern_for_inbox(inbox_id)
|
||||
/^#{CSAT_BASE_NAME}_#{inbox_id}$/
|
||||
end
|
||||
|
||||
def self.versioned_pattern_for_inbox(inbox_id)
|
||||
/^#{CSAT_BASE_NAME}_#{inbox_id}_(\d+)$/
|
||||
end
|
||||
|
||||
private_class_method :csat_base_name_for_inbox, :base_pattern_for_inbox, :versioned_pattern_for_inbox
|
||||
end
|
||||
@@ -1,139 +0,0 @@
|
||||
class Whatsapp::CsatTemplateService
|
||||
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||
DEFAULT_LANGUAGE = 'en'.freeze
|
||||
WHATSAPP_API_VERSION = 'v14.0'.freeze
|
||||
TEMPLATE_CATEGORY = 'MARKETING'.freeze
|
||||
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
|
||||
|
||||
def initialize(whatsapp_channel)
|
||||
@whatsapp_channel = whatsapp_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)
|
||||
response = send_template_creation_request(request_body)
|
||||
process_template_creation_response(response, template_config_with_name)
|
||||
end
|
||||
|
||||
def delete_template(template_name = nil)
|
||||
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(@whatsapp_channel.inbox.id)
|
||||
response = HTTParty.delete(
|
||||
"#{business_account_path}/message_templates?name=#{template_name}",
|
||||
headers: api_headers
|
||||
)
|
||||
{ success: response.success?, response_body: response.body }
|
||||
end
|
||||
|
||||
def get_template_status(template_name)
|
||||
response = HTTParty.get("#{business_account_path}/message_templates?name=#{template_name}", headers: api_headers)
|
||||
|
||||
if response.success? && response['data']&.any?
|
||||
template_data = response['data'].first
|
||||
{
|
||||
success: true,
|
||||
template: {
|
||||
id: template_data['id'], name: template_data['name'],
|
||||
status: template_data['status'], language: template_data['language']
|
||||
}
|
||||
}
|
||||
else
|
||||
{ success: false, error: 'Template not found' }
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error fetching template status: #{e.message}"
|
||||
{ success: false, error: e.message }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
def current_template_name_from_config
|
||||
@whatsapp_channel.inbox.csat_config&.dig('template', 'name')
|
||||
end
|
||||
|
||||
def build_template_request_body(template_config)
|
||||
{
|
||||
name: template_config[:template_name],
|
||||
language: template_config[:language] || DEFAULT_LANGUAGE,
|
||||
category: TEMPLATE_CATEGORY,
|
||||
components: build_template_components(template_config)
|
||||
}
|
||||
end
|
||||
|
||||
def build_template_components(template_config)
|
||||
[
|
||||
build_body_component(template_config[:message]),
|
||||
build_buttons_component(template_config)
|
||||
]
|
||||
end
|
||||
|
||||
def build_body_component(message)
|
||||
{
|
||||
type: 'BODY',
|
||||
text: message
|
||||
}
|
||||
end
|
||||
|
||||
def build_buttons_component(template_config)
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
type: 'URL',
|
||||
text: template_config[:button_text] || DEFAULT_BUTTON_TEXT,
|
||||
url: "#{template_config[:base_url]}/survey/responses/{{1}}",
|
||||
example: ['12345']
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def send_template_creation_request(request_body)
|
||||
HTTParty.post(
|
||||
"#{business_account_path}/message_templates",
|
||||
headers: api_headers,
|
||||
body: request_body.to_json
|
||||
)
|
||||
end
|
||||
|
||||
def process_template_creation_response(response, template_config = {})
|
||||
if response.success?
|
||||
{
|
||||
success: true,
|
||||
template_id: response['id'],
|
||||
template_name: response['name'] || template_config[:template_name],
|
||||
language: template_config[:language] || DEFAULT_LANGUAGE,
|
||||
status: TEMPLATE_STATUS_PENDING
|
||||
}
|
||||
else
|
||||
Rails.logger.error "WhatsApp template creation failed: #{response.code} - #{response.body}"
|
||||
{
|
||||
success: false,
|
||||
error: 'Template creation failed',
|
||||
response_body: response.body
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def business_account_path
|
||||
"#{api_base_path}/#{WHATSAPP_API_VERSION}/#{@whatsapp_channel.provider_config['business_account_id']}"
|
||||
end
|
||||
|
||||
def api_headers
|
||||
{
|
||||
'Authorization' => "Bearer #{@whatsapp_channel.provider_config['api_key']}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
def api_base_path
|
||||
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
|
||||
end
|
||||
end
|
||||
@@ -62,31 +62,12 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
{ 'Authorization' => "Bearer #{whatsapp_channel.provider_config['api_key']}", 'Content-Type' => 'application/json' }
|
||||
end
|
||||
|
||||
def create_csat_template(template_config)
|
||||
csat_template_service.create_template(template_config)
|
||||
end
|
||||
|
||||
def delete_csat_template(template_name = nil)
|
||||
template_name ||= Whatsapp::CsatTemplateNameService.csat_template_name(whatsapp_channel.inbox.id)
|
||||
csat_template_service.delete_template(template_name)
|
||||
end
|
||||
|
||||
def get_template_status(template_name)
|
||||
csat_template_service.get_template_status(template_name)
|
||||
end
|
||||
|
||||
def media_url(media_id, phone_number_id = nil)
|
||||
url = "#{api_base_path}/v13.0/#{media_id}"
|
||||
url += "?phone_number_id=#{phone_number_id}" if phone_number_id
|
||||
url
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def csat_template_service
|
||||
@csat_template_service ||= Whatsapp::CsatTemplateService.new(whatsapp_channel)
|
||||
end
|
||||
|
||||
def api_base_path
|
||||
ENV.fetch('WHATSAPP_CLOUD_BASE_URL', 'https://graph.facebook.com')
|
||||
end
|
||||
|
||||
@@ -34,3 +34,8 @@
|
||||
title: 'Handoff to Human'
|
||||
description: 'Hand off the conversation to a human agent'
|
||||
icon: 'user-switch'
|
||||
|
||||
- id: resolve
|
||||
title: 'Resolve Conversation'
|
||||
description: 'Mark the conversation as resolved'
|
||||
icon: 'checkmark-circle'
|
||||
|
||||
@@ -4,8 +4,8 @@ require 'agents'
|
||||
|
||||
Rails.application.config.after_initialize do
|
||||
api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
|
||||
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || LlmConstants::DEFAULT_MODEL
|
||||
api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || LlmConstants::OPENAI_API_ENDPOINT
|
||||
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
|
||||
api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || OpenAiConstants::DEFAULT_ENDPOINT
|
||||
|
||||
if api_key.present?
|
||||
Agents.configure do |config|
|
||||
|
||||
@@ -214,6 +214,7 @@ en:
|
||||
activity:
|
||||
captain:
|
||||
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
|
||||
tool_resolved: 'Conversation was resolved by %{user_name}'
|
||||
open: 'Conversation was marked open by %{user_name}'
|
||||
agent_bot:
|
||||
error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
|
||||
|
||||
+7
-12
@@ -203,15 +203,7 @@ Rails.application.routes.draw do
|
||||
delete :avatar, on: :member
|
||||
post :sync_templates, on: :member
|
||||
get :health, on: :member
|
||||
if ChatwootApp.enterprise?
|
||||
resource :conference, only: %i[create destroy], controller: 'conference' do
|
||||
get :token, on: :member
|
||||
end
|
||||
end
|
||||
|
||||
resource :csat_template, only: [:show, :create], controller: 'inbox_csat_templates'
|
||||
end
|
||||
|
||||
resources :inbox_members, only: [:create, :show], param: :inbox_id do
|
||||
collection do
|
||||
delete :destroy
|
||||
@@ -425,7 +417,6 @@ Rails.application.routes.draw do
|
||||
get :bot_metrics
|
||||
end
|
||||
end
|
||||
resource :year_in_review, only: [:show]
|
||||
resources :live_reports, only: [] do
|
||||
collection do
|
||||
get :conversation_metrics
|
||||
@@ -557,9 +548,13 @@ Rails.application.routes.draw do
|
||||
resources :delivery_status, only: [:create]
|
||||
|
||||
if ChatwootApp.enterprise?
|
||||
post 'voice/call/:phone', to: 'voice#call_twiml', as: :voice_call
|
||||
post 'voice/status/:phone', to: 'voice#status', as: :voice_status
|
||||
post 'voice/conference_status/:phone', to: 'voice#conference_status', as: :voice_conference_status
|
||||
resource :voice, only: [], controller: 'voice' do
|
||||
collection do
|
||||
post 'call/:phone', action: :call_twiml
|
||||
post 'status/:phone', action: :status
|
||||
post 'conference_status/:phone', action: :conference_status
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
|
||||
before_action :set_voice_inbox_for_conference
|
||||
|
||||
def token
|
||||
render json: Voice::Provider::Twilio::TokenService.new(
|
||||
inbox: @voice_inbox,
|
||||
user: Current.user,
|
||||
account: Current.account
|
||||
).generate
|
||||
end
|
||||
|
||||
def create
|
||||
conversation = fetch_conversation_by_display_id
|
||||
ensure_call_sid!(conversation)
|
||||
|
||||
conference_service = Voice::Provider::Twilio::ConferenceService.new(conversation: conversation)
|
||||
conference_sid = conference_service.ensure_conference_sid
|
||||
conference_service.mark_agent_joined(user: current_user)
|
||||
|
||||
render json: {
|
||||
status: 'success',
|
||||
id: conversation.display_id,
|
||||
conference_sid: conference_sid,
|
||||
using_webrtc: true
|
||||
}
|
||||
end
|
||||
|
||||
def destroy
|
||||
conversation = fetch_conversation_by_display_id
|
||||
Voice::Provider::Twilio::ConferenceService.new(conversation: conversation).end_conference
|
||||
render json: { status: 'success', id: conversation.display_id }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_call_sid!(conversation)
|
||||
return conversation.identifier if conversation.identifier.present?
|
||||
|
||||
incoming_sid = params.require(:call_sid)
|
||||
|
||||
conversation.update!(identifier: incoming_sid)
|
||||
incoming_sid
|
||||
end
|
||||
|
||||
def set_voice_inbox_for_conference
|
||||
@voice_inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
authorize @voice_inbox, :show?
|
||||
end
|
||||
|
||||
def fetch_conversation_by_display_id
|
||||
cid = params[:conversation_id]
|
||||
raise ActiveRecord::RecordNotFound, 'conversation_id required' if cid.blank?
|
||||
|
||||
conversation = @voice_inbox.conversations.find_by!(display_id: cid)
|
||||
authorize conversation, :show?
|
||||
conversation
|
||||
end
|
||||
end
|
||||
@@ -17,7 +17,7 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
|
||||
end
|
||||
|
||||
def destroy
|
||||
::DeleteObjectJob.perform_later(@sla_policy, Current.user, request.ip) if @sla_policy.present?
|
||||
@sla_policy.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ module Enterprise::Public::Api::V1::Portals::ArticlesController
|
||||
|
||||
def search_articles
|
||||
if @portal.account.feature_enabled?('help_center_embedding_search')
|
||||
@articles = @articles.vector_search(list_params.merge(account_id: @portal.account_id)) if list_params[:query].present?
|
||||
@articles = @articles.vector_search(list_params) if list_params[:query].present?
|
||||
else
|
||||
super
|
||||
end
|
||||
|
||||
@@ -151,8 +151,9 @@ class Twilio::VoiceController < ApplicationController
|
||||
end
|
||||
|
||||
def conference_status_callback_url
|
||||
host = ENV.fetch('FRONTEND_URL', '')
|
||||
phone_digits = inbox_channel.phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
|
||||
"#{host}/twilio/voice/conference_status/#{phone_digits}"
|
||||
end
|
||||
|
||||
def find_conversation_for_conference!(friendly_name:, call_sid:)
|
||||
|
||||
@@ -21,18 +21,20 @@ module Captain::ChatHelper
|
||||
|
||||
def build_chat
|
||||
llm_chat = chat(model: @model, temperature: temperature)
|
||||
llm_chat = llm_chat.with_params(response_format: { type: 'json_object' })
|
||||
llm_chat.with_params(response_format: { type: 'json_object' })
|
||||
|
||||
llm_chat = setup_tools(llm_chat)
|
||||
llm_chat = setup_system_instructions(llm_chat)
|
||||
setup_system_instructions(llm_chat)
|
||||
setup_event_handlers(llm_chat)
|
||||
|
||||
llm_chat
|
||||
end
|
||||
|
||||
def setup_tools(llm_chat)
|
||||
def setup_tools(chat)
|
||||
@tools&.each do |tool|
|
||||
llm_chat = llm_chat.with_tool(tool)
|
||||
chat.with_tool(tool)
|
||||
end
|
||||
llm_chat
|
||||
chat
|
||||
end
|
||||
|
||||
def setup_system_instructions(chat)
|
||||
|
||||
@@ -87,15 +87,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
I18n.with_locale(@assistant.account.locale) do
|
||||
create_handoff_message
|
||||
@conversation.bot_handoff!
|
||||
send_out_of_office_message_if_applicable
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def send_out_of_office_message_if_applicable
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
|
||||
end
|
||||
|
||||
def create_handoff_message
|
||||
create_outgoing_message(
|
||||
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
|
||||
|
||||
@@ -26,7 +26,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def generate_standard_faqs(document)
|
||||
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name, account_id: document.account_id).generate
|
||||
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name).generate
|
||||
end
|
||||
|
||||
def build_paginated_service(document, options)
|
||||
|
||||
@@ -2,8 +2,7 @@ class Captain::Llm::UpdateEmbeddingJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(record, content)
|
||||
account_id = record.account_id
|
||||
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(content)
|
||||
embedding = Captain::Llm::EmbeddingService.new.get_embedding(content)
|
||||
record.update!(embedding: embedding)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
module Enterprise::DeleteObjectJob
|
||||
private
|
||||
|
||||
def heavy_associations
|
||||
super.merge(
|
||||
SlaPolicy => %i[applied_slas]
|
||||
).freeze
|
||||
end
|
||||
|
||||
def process_post_deletion_tasks(object, user, ip)
|
||||
create_audit_entry(object, user, ip)
|
||||
end
|
||||
|
||||
def create_audit_entry(object, user, ip)
|
||||
return unless %w[Inbox Conversation SlaPolicy].include?(object.class.to_s) && user.present?
|
||||
return unless %w[Inbox Conversation].include?(object.class.to_s) && user.present?
|
||||
|
||||
Enterprise::AuditLog.create(
|
||||
auditable: object,
|
||||
|
||||
@@ -19,8 +19,6 @@ class ArticleEmbedding < ApplicationRecord
|
||||
|
||||
after_commit :update_response_embedding
|
||||
|
||||
delegate :account_id, to: :article
|
||||
|
||||
private
|
||||
|
||||
def update_response_embedding
|
||||
|
||||
@@ -94,7 +94,8 @@ class Captain::Assistant < ApplicationRecord
|
||||
def agent_tools
|
||||
[
|
||||
self.class.resolve_tool_class('faq_lookup').new(self),
|
||||
self.class.resolve_tool_class('handoff').new(self)
|
||||
self.class.resolve_tool_class('handoff').new(self),
|
||||
self.class.resolve_tool_class('resolve').new(self)
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ class Captain::AssistantResponse < ApplicationRecord
|
||||
|
||||
enum status: { pending: 0, approved: 1 }
|
||||
|
||||
def self.search(query, account_id: nil)
|
||||
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(query)
|
||||
def self.search(query)
|
||||
embedding = Captain::Llm::EmbeddingService.new.get_embedding(query)
|
||||
nearest_neighbors(:embedding, embedding, distance: 'cosine').limit(5)
|
||||
end
|
||||
|
||||
|
||||
@@ -42,13 +42,11 @@ class Channel::Voice < ApplicationRecord
|
||||
false
|
||||
end
|
||||
|
||||
def initiate_call(to:, conference_sid: nil, agent_id: nil)
|
||||
def initiate_call(to:)
|
||||
case provider
|
||||
when 'twilio'
|
||||
Voice::Provider::Twilio::Adapter.new(self).initiate_call(
|
||||
to: to,
|
||||
conference_sid: conference_sid,
|
||||
agent_id: agent_id
|
||||
Voice::Provider::TwilioAdapter.new(self).initiate_call(
|
||||
to: to
|
||||
)
|
||||
else
|
||||
raise "Unsupported voice provider: #{provider}"
|
||||
@@ -58,12 +56,12 @@ class Channel::Voice < ApplicationRecord
|
||||
# Public URLs used to configure Twilio webhooks
|
||||
def voice_call_webhook_url
|
||||
digits = phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/call/#{digits}"
|
||||
end
|
||||
|
||||
def voice_status_webhook_url
|
||||
digits = phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: digits)
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/voice/status/#{digits}"
|
||||
end
|
||||
|
||||
private
|
||||
@@ -89,6 +87,7 @@ class Channel::Voice < ApplicationRecord
|
||||
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
||||
end
|
||||
end
|
||||
# twilio_client and initiate_twilio_call moved to Voice::Provider::TwilioAdapter
|
||||
|
||||
def provider_config_hash
|
||||
if provider_config.is_a?(Hash)
|
||||
|
||||
@@ -43,7 +43,7 @@ module Concerns::Agentable
|
||||
end
|
||||
|
||||
def agent_model
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || LlmConstants::DEFAULT_MODEL
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
|
||||
end
|
||||
|
||||
def agent_response_schema
|
||||
|
||||
@@ -66,31 +66,6 @@ module Concerns::Toolable
|
||||
[auth_config['username'], auth_config['password']]
|
||||
end
|
||||
|
||||
def build_metadata_headers(state)
|
||||
{}.tap do |headers|
|
||||
add_base_headers(headers, state)
|
||||
add_conversation_headers(headers, state[:conversation]) if state[:conversation]
|
||||
add_contact_headers(headers, state[:contact]) if state[:contact]
|
||||
end
|
||||
end
|
||||
|
||||
def add_base_headers(headers, state)
|
||||
headers['X-Chatwoot-Account-Id'] = state[:account_id].to_s if state[:account_id]
|
||||
headers['X-Chatwoot-Assistant-Id'] = state[:assistant_id].to_s if state[:assistant_id]
|
||||
headers['X-Chatwoot-Tool-Slug'] = slug if slug.present?
|
||||
end
|
||||
|
||||
def add_conversation_headers(headers, conversation)
|
||||
headers['X-Chatwoot-Conversation-Id'] = conversation[:id].to_s if conversation[:id]
|
||||
headers['X-Chatwoot-Conversation-Display-Id'] = conversation[:display_id].to_s if conversation[:display_id]
|
||||
end
|
||||
|
||||
def add_contact_headers(headers, contact)
|
||||
headers['X-Chatwoot-Contact-Id'] = contact[:id].to_s if contact[:id]
|
||||
headers['X-Chatwoot-Contact-Email'] = contact[:email].to_s if contact[:email].present?
|
||||
headers['X-Chatwoot-Contact-Phone'] = contact[:phone_number].to_s if contact[:phone_number].present?
|
||||
end
|
||||
|
||||
def format_response(raw_response_body)
|
||||
return raw_response_body if response_template.blank?
|
||||
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
module Enterprise::ActivityMessageHandler
|
||||
def automation_status_change_activity_content
|
||||
if Current.executed_by.instance_of?(Captain::Assistant)
|
||||
locale = Current.executed_by.account.locale
|
||||
if resolved?
|
||||
I18n.t(
|
||||
'conversations.activity.captain.resolved',
|
||||
user_name: Current.executed_by.name,
|
||||
locale: locale
|
||||
)
|
||||
elsif open?
|
||||
I18n.t(
|
||||
'conversations.activity.captain.open',
|
||||
user_name: Current.executed_by.name,
|
||||
locale: locale
|
||||
)
|
||||
end
|
||||
else
|
||||
super
|
||||
end
|
||||
return super unless Current.executed_by.instance_of?(Captain::Assistant)
|
||||
return unless resolved? || open?
|
||||
|
||||
locale = Current.executed_by.account.locale
|
||||
user_name = Current.executed_by.name
|
||||
key = captain_activity_key
|
||||
|
||||
I18n.t(key, user_name: user_name, locale: locale)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def captain_activity_key
|
||||
return 'conversations.activity.captain.open' if open?
|
||||
return 'conversations.activity.captain.tool_resolved' if Current.tool_name == 'resolve'
|
||||
|
||||
'conversations.activity.captain.resolved'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,7 +11,7 @@ module Enterprise::Concerns::Article
|
||||
add_article_embedding_association
|
||||
|
||||
def self.vector_search(params)
|
||||
embedding = Captain::Llm::EmbeddingService.new(account_id: params[:account_id]).get_embedding(params['query'])
|
||||
embedding = Captain::Llm::EmbeddingService.new.get_embedding(params['query'])
|
||||
records = joins(
|
||||
:category
|
||||
).search_by_category_slug(
|
||||
|
||||
@@ -22,7 +22,7 @@ class SlaPolicy < ApplicationRecord
|
||||
validates :name, presence: true
|
||||
|
||||
has_many :conversations, dependent: :nullify
|
||||
has_many :applied_slas, dependent: :destroy_async
|
||||
has_many :applied_slas, dependent: :destroy
|
||||
|
||||
def push_event_data
|
||||
{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Captain::Llm::ContactAttributesService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
class Captain::Llm::ContactAttributesService < Llm::LegacyBaseOpenAiService
|
||||
def initialize(assistant, conversation)
|
||||
super()
|
||||
@assistant = assistant
|
||||
@@ -18,38 +17,33 @@ class Captain::Llm::ContactAttributesService < Llm::BaseAiService
|
||||
attr_reader :content
|
||||
|
||||
def generate_attributes
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
chat
|
||||
.with_params(response_format: { type: 'json_object' })
|
||||
.with_instructions(system_prompt)
|
||||
.ask(@content)
|
||||
end
|
||||
parse_response(response.content)
|
||||
rescue RubyLLM::Error => e
|
||||
ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
|
||||
response = @client.chat(parameters: chat_parameters)
|
||||
parse_response(response)
|
||||
rescue OpenAI::Error => e
|
||||
Rails.logger.error "OpenAI API Error: #{e.message}"
|
||||
[]
|
||||
end
|
||||
|
||||
def instrumentation_params
|
||||
def chat_parameters
|
||||
prompt = Captain::Llm::SystemPromptsService.attributes_generator
|
||||
{
|
||||
span_name: 'llm.captain.contact_attributes',
|
||||
model: @model,
|
||||
temperature: @temperature,
|
||||
account_id: @conversation.account_id,
|
||||
feature_name: 'contact_attributes',
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: @content }
|
||||
],
|
||||
metadata: { assistant_id: @assistant.id, contact_id: @contact.id }
|
||||
{
|
||||
role: 'system',
|
||||
content: prompt
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: content
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
Captain::Llm::SystemPromptsService.attributes_generator
|
||||
end
|
||||
|
||||
def parse_response(content)
|
||||
def parse_response(response)
|
||||
content = response.dig('choices', 0, 'message', 'content')
|
||||
return [] if content.nil?
|
||||
|
||||
JSON.parse(content.strip).fetch('attributes', [])
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Captain::Llm::ContactNotesService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
class Captain::Llm::ContactNotesService < Llm::LegacyBaseOpenAiService
|
||||
def initialize(assistant, conversation)
|
||||
super()
|
||||
@assistant = assistant
|
||||
@@ -19,42 +18,38 @@ class Captain::Llm::ContactNotesService < Llm::BaseAiService
|
||||
attr_reader :content
|
||||
|
||||
def generate_notes
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
chat
|
||||
.with_params(response_format: { type: 'json_object' })
|
||||
.with_instructions(system_prompt)
|
||||
.ask(@content)
|
||||
end
|
||||
parse_response(response.content)
|
||||
rescue RubyLLM::Error => e
|
||||
ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
|
||||
response = @client.chat(parameters: chat_parameters)
|
||||
parse_response(response)
|
||||
rescue OpenAI::Error => e
|
||||
Rails.logger.error "OpenAI API Error: #{e.message}"
|
||||
[]
|
||||
end
|
||||
|
||||
def instrumentation_params
|
||||
def chat_parameters
|
||||
account_language = @conversation.account.locale_english_name
|
||||
prompt = Captain::Llm::SystemPromptsService.notes_generator(account_language)
|
||||
|
||||
{
|
||||
span_name: 'llm.captain.contact_notes',
|
||||
model: @model,
|
||||
temperature: @temperature,
|
||||
account_id: @conversation.account_id,
|
||||
feature_name: 'contact_notes',
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: @content }
|
||||
],
|
||||
metadata: { assistant_id: @assistant.id, contact_id: @contact.id }
|
||||
{
|
||||
role: 'system',
|
||||
content: prompt
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: content
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
account_language = @conversation.account.locale_english_name
|
||||
Captain::Llm::SystemPromptsService.notes_generator(account_language)
|
||||
end
|
||||
|
||||
def parse_response(response)
|
||||
return [] if response.nil?
|
||||
content = response.dig('choices', 0, 'message', 'content')
|
||||
return [] if content.nil?
|
||||
|
||||
JSON.parse(response.strip).fetch('notes', [])
|
||||
JSON.parse(content.strip).fetch('notes', [])
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Error in parsing GPT processed response: #{e.message}"
|
||||
[]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Captain::Llm::ConversationFaqService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
class Captain::Llm::ConversationFaqService < Llm::LegacyBaseOpenAiService
|
||||
DISTANCE_THRESHOLD = 0.3
|
||||
|
||||
def initialize(assistant, conversation)
|
||||
@@ -36,7 +35,7 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
|
||||
|
||||
faqs.each do |faq|
|
||||
combined_text = "#{faq['question']}: #{faq['answer']}"
|
||||
embedding = Captain::Llm::EmbeddingService.new(account_id: @conversation.account_id).get_embedding(combined_text)
|
||||
embedding = Captain::Llm::EmbeddingService.new.get_embedding(combined_text)
|
||||
similar_faqs = find_similar_faqs(embedding)
|
||||
|
||||
if similar_faqs.any?
|
||||
@@ -82,43 +81,38 @@ class Captain::Llm::ConversationFaqService < Llm::BaseAiService
|
||||
end
|
||||
|
||||
def generate
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
chat
|
||||
.with_params(response_format: { type: 'json_object' })
|
||||
.with_instructions(system_prompt)
|
||||
.ask(@content)
|
||||
end
|
||||
parse_response(response.content)
|
||||
rescue RubyLLM::Error => e
|
||||
Rails.logger.error "LLM API Error: #{e.message}"
|
||||
response = @client.chat(parameters: chat_parameters)
|
||||
parse_response(response)
|
||||
rescue OpenAI::Error => e
|
||||
Rails.logger.error "OpenAI API Error: #{e.message}"
|
||||
[]
|
||||
end
|
||||
|
||||
def instrumentation_params
|
||||
def chat_parameters
|
||||
account_language = @conversation.account.locale_english_name
|
||||
prompt = Captain::Llm::SystemPromptsService.conversation_faq_generator(account_language)
|
||||
|
||||
{
|
||||
span_name: 'llm.captain.conversation_faq',
|
||||
model: @model,
|
||||
temperature: @temperature,
|
||||
account_id: @conversation.account_id,
|
||||
conversation_id: @conversation.id,
|
||||
feature_name: 'conversation_faq',
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: @content }
|
||||
],
|
||||
metadata: { assistant_id: @assistant.id }
|
||||
{
|
||||
role: 'system',
|
||||
content: prompt
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: content
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
account_language = @conversation.account.locale_english_name
|
||||
Captain::Llm::SystemPromptsService.conversation_faq_generator(account_language)
|
||||
end
|
||||
|
||||
def parse_response(response)
|
||||
return [] if response.nil?
|
||||
content = response.dig('choices', 0, 'message', 'content')
|
||||
return [] if content.nil?
|
||||
|
||||
JSON.parse(response.strip).fetch('faqs', [])
|
||||
JSON.parse(content.strip).fetch('faqs', [])
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Error in parsing GPT processed response: #{e.message}"
|
||||
[]
|
||||
|
||||
@@ -1,38 +1,22 @@
|
||||
class Captain::Llm::EmbeddingService
|
||||
include Integrations::LlmInstrumentation
|
||||
require 'openai'
|
||||
|
||||
class Captain::Llm::EmbeddingService < Llm::LegacyBaseOpenAiService
|
||||
class EmbeddingsError < StandardError; end
|
||||
|
||||
def initialize(account_id: nil)
|
||||
Llm::Config.initialize!
|
||||
@account_id = account_id
|
||||
@embedding_model = InstallationConfig.find_by(name: 'CAPTAIN_EMBEDDING_MODEL')&.value.presence || LlmConstants::DEFAULT_EMBEDDING_MODEL
|
||||
end
|
||||
|
||||
def self.embedding_model
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_EMBEDDING_MODEL')&.value.presence || LlmConstants::DEFAULT_EMBEDDING_MODEL
|
||||
@embedding_model = InstallationConfig.find_by(name: 'CAPTAIN_EMBEDDING_MODEL')&.value.presence || OpenAiConstants::DEFAULT_EMBEDDING_MODEL
|
||||
end
|
||||
|
||||
def get_embedding(content, model: @embedding_model)
|
||||
return [] if content.blank?
|
||||
def get_embedding(content, model: self.class.embedding_model)
|
||||
response = @client.embeddings(
|
||||
parameters: {
|
||||
model: model,
|
||||
input: content
|
||||
}
|
||||
)
|
||||
|
||||
instrument_embedding_call(instrumentation_params(content, model)) do
|
||||
RubyLLM.embed(content, model: model).vectors
|
||||
end
|
||||
rescue RubyLLM::Error => e
|
||||
Rails.logger.error "Embedding API Error: #{e.message}"
|
||||
response.dig('data', 0, 'embedding')
|
||||
rescue StandardError => e
|
||||
raise EmbeddingsError, "Failed to create an embedding: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def instrumentation_params(content, model)
|
||||
{
|
||||
span_name: 'llm.captain.embedding',
|
||||
model: model,
|
||||
input: content,
|
||||
feature_name: 'embedding',
|
||||
account_id: @account_id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
def initialize(content, language = 'english', account_id: nil)
|
||||
class Captain::Llm::FaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
def initialize(content, language = 'english')
|
||||
super()
|
||||
@language = language
|
||||
@content = content
|
||||
@account_id = account_id
|
||||
end
|
||||
|
||||
def generate
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
chat
|
||||
.with_params(response_format: { type: 'json_object' })
|
||||
.with_instructions(system_prompt)
|
||||
.ask(@content)
|
||||
end
|
||||
|
||||
parse_response(response.content)
|
||||
rescue RubyLLM::Error => e
|
||||
Rails.logger.error "LLM API Error: #{e.message}"
|
||||
response = @client.chat(parameters: chat_parameters)
|
||||
parse_response(response)
|
||||
rescue OpenAI::Error => e
|
||||
Rails.logger.error "OpenAI API Error: #{e.message}"
|
||||
[]
|
||||
end
|
||||
|
||||
@@ -26,25 +17,26 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
|
||||
|
||||
attr_reader :content, :language
|
||||
|
||||
def system_prompt
|
||||
Captain::Llm::SystemPromptsService.faq_generator(language)
|
||||
end
|
||||
|
||||
def instrumentation_params
|
||||
def chat_parameters
|
||||
prompt = Captain::Llm::SystemPromptsService.faq_generator(language)
|
||||
{
|
||||
span_name: 'llm.captain.faq_generator',
|
||||
model: @model,
|
||||
temperature: @temperature,
|
||||
feature_name: 'faq_generator',
|
||||
account_id: @account_id,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: @content }
|
||||
{
|
||||
role: 'system',
|
||||
content: prompt
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: content
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def parse_response(content)
|
||||
def parse_response(response)
|
||||
content = response.dig('choices', 0, 'message', 'content')
|
||||
return [] if content.nil?
|
||||
|
||||
JSON.parse(content.strip).fetch('faqs', [])
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
# Default pages per chunk - easily configurable
|
||||
DEFAULT_PAGES_PER_CHUNK = 10
|
||||
MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops
|
||||
@@ -15,7 +13,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
@max_pages = options[:max_pages] # Optional limit from UI
|
||||
@total_pages_processed = 0
|
||||
@iterations_completed = 0
|
||||
@model = LlmConstants::PDF_PROCESSING_MODEL
|
||||
@model = OpenAiConstants::PDF_PROCESSING_MODEL
|
||||
end
|
||||
|
||||
def generate
|
||||
@@ -45,19 +43,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
private
|
||||
|
||||
def generate_standard_faqs
|
||||
params = standard_chat_parameters
|
||||
instrumentation_params = {
|
||||
span_name: 'llm.faq_generation',
|
||||
account_id: @document&.account_id,
|
||||
feature_name: 'faq_generation',
|
||||
model: @model,
|
||||
messages: params[:messages]
|
||||
}
|
||||
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
@client.chat(parameters: params)
|
||||
end
|
||||
|
||||
response = @client.chat(parameters: standard_chat_parameters)
|
||||
parse_response(response)
|
||||
rescue OpenAI::Error => e
|
||||
Rails.logger.error I18n.t('captain.documents.openai_api_error', error: e.message)
|
||||
@@ -98,13 +84,7 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
|
||||
def process_page_chunk(start_page, end_page)
|
||||
params = build_chunk_parameters(start_page, end_page)
|
||||
|
||||
instrumentation_params = build_instrumentation_params(params, start_page, end_page)
|
||||
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
@client.chat(parameters: params)
|
||||
end
|
||||
|
||||
response = @client.chat(parameters: params)
|
||||
result = parse_chunk_response(response)
|
||||
{ faqs: result['faqs'] || [], has_content: result['has_content'] != false }
|
||||
rescue OpenAI::Error => e
|
||||
@@ -200,26 +180,21 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
def similarity_score(str1, str2)
|
||||
words1 = str1.downcase.split(/\W+/).reject(&:empty?)
|
||||
words2 = str2.downcase.split(/\W+/).reject(&:empty?)
|
||||
|
||||
common_words = words1 & words2
|
||||
total_words = (words1 + words2).uniq.size
|
||||
|
||||
return 0 if total_words.zero?
|
||||
|
||||
common_words.size.to_f / total_words
|
||||
end
|
||||
|
||||
def build_instrumentation_params(params, start_page, end_page)
|
||||
{
|
||||
span_name: 'llm.paginated_faq_generation',
|
||||
account_id: @document&.account_id,
|
||||
feature_name: 'paginated_faq_generation',
|
||||
model: @model,
|
||||
messages: params[:messages],
|
||||
metadata: {
|
||||
document_id: @document&.id,
|
||||
start_page: start_page,
|
||||
end_page: end_page,
|
||||
iteration: @iterations_completed + 1
|
||||
}
|
||||
}
|
||||
def determine_stop_reason(last_chunk_result)
|
||||
return 'Maximum iterations reached' if @iterations_completed >= MAX_ITERATIONS
|
||||
return 'Maximum pages processed' if @max_pages && @total_pages_processed >= @max_pages
|
||||
return 'No content found in last chunk' if last_chunk_result[:faqs].empty?
|
||||
return 'End of document reached' if last_chunk_result[:has_content] == false
|
||||
|
||||
'Unknown'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
def initialize(document)
|
||||
super()
|
||||
@document = document
|
||||
@@ -21,30 +19,13 @@ class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
|
||||
|
||||
def upload_pdf_to_openai
|
||||
with_tempfile do |temp_file|
|
||||
instrument_file_upload do
|
||||
response = @client.files.upload(
|
||||
parameters: {
|
||||
file: temp_file,
|
||||
purpose: 'assistants'
|
||||
}
|
||||
)
|
||||
response['id']
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def instrument_file_upload(&)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
tracer.in_span('llm.file.upload') do |span|
|
||||
span.set_attribute('gen_ai.provider', 'openai')
|
||||
span.set_attribute('file.purpose', 'assistants')
|
||||
span.set_attribute(ATTR_LANGFUSE_USER_ID, document.account_id.to_s)
|
||||
span.set_attribute(ATTR_LANGFUSE_TAGS, ['pdf_upload'].to_json)
|
||||
span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'document_id'), document.id.to_s)
|
||||
file_id = yield
|
||||
span.set_attribute('file.id', file_id) if file_id
|
||||
file_id
|
||||
response = @client.files.upload(
|
||||
parameters: {
|
||||
file: temp_file,
|
||||
purpose: 'assistants'
|
||||
}
|
||||
)
|
||||
response['id']
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
class Captain::Onboarding::WebsiteAnalyzerService < Llm::LegacyBaseOpenAiService
|
||||
MAX_CONTENT_LENGTH = 8000
|
||||
|
||||
def initialize(website_url)
|
||||
@@ -58,29 +57,19 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
|
||||
end
|
||||
|
||||
def extract_business_info
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
chat
|
||||
.with_params(response_format: { type: 'json_object' }, max_tokens: 1000)
|
||||
.with_temperature(0.1)
|
||||
.with_instructions(build_analysis_prompt)
|
||||
.ask(@website_content)
|
||||
end
|
||||
prompt = build_analysis_prompt
|
||||
|
||||
parse_llm_response(response.content)
|
||||
end
|
||||
response = client.chat(
|
||||
parameters: {
|
||||
model: model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: 0.1,
|
||||
max_tokens: 1000
|
||||
}
|
||||
)
|
||||
|
||||
def instrumentation_params
|
||||
{
|
||||
span_name: 'llm.captain.website_analyzer',
|
||||
model: @model,
|
||||
temperature: 0.1,
|
||||
feature_name: 'website_analyzer',
|
||||
messages: [
|
||||
{ role: 'system', content: build_analysis_prompt },
|
||||
{ role: 'user', content: @website_content }
|
||||
],
|
||||
metadata: { website_url: @website_url }
|
||||
}
|
||||
parse_llm_response(response.dig('choices', 0, 'message', 'content'))
|
||||
end
|
||||
|
||||
def build_analysis_prompt
|
||||
@@ -106,7 +95,7 @@ class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseAiService
|
||||
end
|
||||
|
||||
def parse_llm_response(response_text)
|
||||
parsed_response = JSON.parse(response_text.strip)
|
||||
parsed_response = JSON.parse(response_text)
|
||||
|
||||
{
|
||||
success: true,
|
||||
|
||||
@@ -9,24 +9,6 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
schedule_captain_response
|
||||
end
|
||||
|
||||
def should_send_greeting?
|
||||
return false if captain_handling_conversation?
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def should_send_out_of_office_message?
|
||||
return false if captain_handling_conversation?
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def should_send_email_collect?
|
||||
return false if captain_handling_conversation?
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def schedule_captain_response
|
||||
@@ -64,14 +46,5 @@ module Enterprise::MessageTemplates::HookExecutionService
|
||||
content: 'Transferring to another agent for further assistance.'
|
||||
)
|
||||
conversation.bot_handoff!
|
||||
send_out_of_office_message_after_handoff
|
||||
end
|
||||
|
||||
def send_out_of_office_message_after_handoff
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||
end
|
||||
|
||||
def captain_handling_conversation?
|
||||
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,61 +1,50 @@
|
||||
class Internal::AccountAnalysis::ContentEvaluatorService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
class Internal::AccountAnalysis::ContentEvaluatorService < Llm::LegacyBaseOpenAiService
|
||||
def initialize
|
||||
Llm::Config.initialize!
|
||||
super()
|
||||
|
||||
@model = 'gpt-4o-mini'.freeze
|
||||
end
|
||||
|
||||
def evaluate(content)
|
||||
return default_evaluation if content.blank?
|
||||
|
||||
moderation_result = instrument_moderation_call(instrumentation_params(content)) do
|
||||
RubyLLM.moderate(content.to_s[0...10_000])
|
||||
begin
|
||||
response = send_to_llm(content)
|
||||
evaluation = handle_response(response)
|
||||
log_evaluation_results(evaluation)
|
||||
evaluation
|
||||
rescue StandardError => e
|
||||
handle_evaluation_error(e)
|
||||
end
|
||||
|
||||
build_evaluation(moderation_result)
|
||||
rescue StandardError => e
|
||||
handle_evaluation_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def instrumentation_params(content)
|
||||
def send_to_llm(content)
|
||||
Rails.logger.info('Sending content to LLM for security evaluation')
|
||||
@client.chat(
|
||||
parameters: {
|
||||
model: @model,
|
||||
messages: llm_messages(content),
|
||||
response_format: { type: 'json_object' }
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def handle_response(response)
|
||||
return default_evaluation if response.nil?
|
||||
|
||||
parsed = JSON.parse(response.dig('choices', 0, 'message', 'content').strip)
|
||||
|
||||
{
|
||||
span_name: 'llm.internal.content_moderation',
|
||||
model: 'text-moderation-latest',
|
||||
input: content,
|
||||
feature_name: 'content_evaluator'
|
||||
'threat_level' => parsed['threat_level'] || 'unknown',
|
||||
'threat_summary' => parsed['threat_summary'] || 'No threat summary provided',
|
||||
'detected_threats' => parsed['detected_threats'] || [],
|
||||
'illegal_activities_detected' => parsed['illegal_activities_detected'] || false,
|
||||
'recommendation' => parsed['recommendation'] || 'review'
|
||||
}
|
||||
end
|
||||
|
||||
def build_evaluation(result)
|
||||
flagged = result.flagged?
|
||||
categories = result.flagged_categories
|
||||
|
||||
evaluation = {
|
||||
'threat_level' => flagged ? determine_threat_level(result) : 'safe',
|
||||
'threat_summary' => flagged ? "Content flagged for: #{categories.join(', ')}" : 'No threats detected',
|
||||
'detected_threats' => categories,
|
||||
'illegal_activities_detected' => categories.any? { |c| c.include?('violence') || c.include?('self-harm') },
|
||||
'recommendation' => flagged ? 'review' : 'approve'
|
||||
}
|
||||
|
||||
log_evaluation_results(evaluation)
|
||||
evaluation
|
||||
end
|
||||
|
||||
def determine_threat_level(result)
|
||||
scores = result.category_scores
|
||||
max_score = scores.values.max || 0
|
||||
|
||||
case max_score
|
||||
when 0.8.. then 'critical'
|
||||
when 0.5..0.8 then 'high'
|
||||
when 0.2..0.5 then 'medium'
|
||||
else 'low'
|
||||
end
|
||||
end
|
||||
|
||||
def default_evaluation(error_type = nil)
|
||||
{
|
||||
'threat_level' => 'unknown',
|
||||
@@ -67,11 +56,18 @@ class Internal::AccountAnalysis::ContentEvaluatorService
|
||||
end
|
||||
|
||||
def log_evaluation_results(evaluation)
|
||||
Rails.logger.info("Moderation evaluation - Level: #{evaluation['threat_level']}, Threats: #{evaluation['detected_threats'].join(', ')}")
|
||||
Rails.logger.info("LLM evaluation - Level: #{evaluation['threat_level']}, Illegal activities: #{evaluation['illegal_activities_detected']}")
|
||||
end
|
||||
|
||||
def handle_evaluation_error(error)
|
||||
Rails.logger.error("Error evaluating content: #{error.message}")
|
||||
default_evaluation('evaluation_failure')
|
||||
end
|
||||
|
||||
def llm_messages(content)
|
||||
[
|
||||
{ role: 'system', content: 'You are a security analysis system that evaluates content for potential threats and scams.' },
|
||||
{ role: 'user', content: Internal::AccountAnalysis::PromptsService.threat_analyser(content.to_s[0...10_000]) }
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,6 +14,8 @@ class Llm::BaseAiService
|
||||
setup_temperature
|
||||
end
|
||||
|
||||
# Returns a configured RubyLLM chat instance.
|
||||
# Subclasses can override model/temperature via instance variables or pass them explicitly.
|
||||
def chat(model: @model, temperature: @temperature)
|
||||
RubyLLM.chat(model: model).with_temperature(temperature)
|
||||
end
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# DEPRECATED: This class uses the legacy OpenAI Ruby gem directly.
|
||||
# Only used for PDF/file operations that require OpenAI's files API:
|
||||
# - Captain::Llm::PdfProcessingService (files.upload for assistants)
|
||||
# - Captain::Llm::PaginatedFaqGeneratorService (uses file_id from uploaded files)
|
||||
#
|
||||
# For all other LLM operations, use Llm::BaseAiService with RubyLLM instead.
|
||||
# New features should use Llm::BaseAiService with RubyLLM instead.
|
||||
# This class will be removed once all services are migrated to RubyLLM.
|
||||
class Llm::LegacyBaseOpenAiService
|
||||
DEFAULT_MODEL = 'gpt-4o-mini'
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
WHISPER_MODEL = 'whisper-1'.freeze
|
||||
|
||||
class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService
|
||||
attr_reader :attachment, :message, :account
|
||||
|
||||
def initialize(attachment)
|
||||
@@ -50,7 +46,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
|
||||
temp_file_path = fetch_audio_file
|
||||
|
||||
transcribed_text = nil
|
||||
response_text = nil
|
||||
|
||||
File.open(temp_file_path, 'rb') do |file|
|
||||
response = @client.audio.transcribe(
|
||||
@@ -60,23 +56,14 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
temperature: 0.4
|
||||
}
|
||||
)
|
||||
transcribed_text = response['text']
|
||||
|
||||
response_text = response['text']
|
||||
end
|
||||
|
||||
FileUtils.rm_f(temp_file_path)
|
||||
|
||||
update_transcription(transcribed_text)
|
||||
transcribed_text
|
||||
end
|
||||
|
||||
def instrumentation_params(file_path)
|
||||
{
|
||||
span_name: 'llm.messages.audio_transcription',
|
||||
model: WHISPER_MODEL,
|
||||
account_id: account&.id,
|
||||
feature_name: 'audio_transcription',
|
||||
file_path: file_path
|
||||
}
|
||||
update_transcription(response_text)
|
||||
response_text
|
||||
end
|
||||
|
||||
def update_transcription(transcribed_text)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
class Sla::EvaluateAppliedSlaService
|
||||
pattr_initialize [:applied_sla!]
|
||||
include ReportingEventHelper
|
||||
|
||||
def perform
|
||||
check_sla_thresholds
|
||||
@@ -22,58 +21,12 @@ class Sla::EvaluateAppliedSlaService
|
||||
end
|
||||
end
|
||||
|
||||
def should_use_business_hours?(sla_policy, inbox)
|
||||
sla_policy.only_during_business_hours? && inbox.working_hours_enabled?
|
||||
end
|
||||
|
||||
# Calculates the SLA threshold deadline considering business hours if enabled.
|
||||
#
|
||||
# This method determines when an SLA will be breached by adding the threshold duration
|
||||
# to the start time. If business hours are enabled, it only counts time during working hours,
|
||||
# automatically skipping weekends and after-hours periods.
|
||||
def calculate_threshold_deadline(start_time, threshold_seconds, inbox, sla_policy)
|
||||
# Fall back to simple calendar time calculation if business hours not enabled
|
||||
return start_time.to_i + threshold_seconds unless should_use_business_hours?(sla_policy, inbox)
|
||||
|
||||
# Configure the working_hours gem with inbox-specific schedule and timezone
|
||||
configure_working_hours_for_calculation(inbox)
|
||||
|
||||
# Convert start time to inbox timezone for accurate business hours calculation
|
||||
start_time_in_timezone = start_time.in_time_zone(inbox.timezone).to_time
|
||||
|
||||
# Determine effective start time: if outside business hours, advance to next working time
|
||||
# Example: Saturday 6 PM conversation would start counting Monday 9 AM
|
||||
effective_start_time = if start_time_in_timezone.in_working_hours?
|
||||
start_time_in_timezone
|
||||
else
|
||||
WorkingHours.next_working_time(start_time_in_timezone)
|
||||
end
|
||||
|
||||
# Add working time duration
|
||||
# This automatically skips non-working hours, weekends, and holidays
|
||||
deadline = effective_start_time + threshold_seconds.working.seconds
|
||||
deadline.to_i
|
||||
end
|
||||
|
||||
def configure_working_hours_for_calculation(inbox)
|
||||
inbox_working_hours = configure_working_hours(inbox.working_hours)
|
||||
return if inbox_working_hours.blank?
|
||||
|
||||
WorkingHours::Config.working_hours = inbox_working_hours
|
||||
WorkingHours::Config.time_zone = inbox.timezone
|
||||
end
|
||||
|
||||
def still_within_threshold?(threshold)
|
||||
Time.zone.now.to_i < threshold
|
||||
end
|
||||
|
||||
def check_first_response_time_threshold(applied_sla, conversation, sla_policy)
|
||||
threshold = calculate_threshold_deadline(
|
||||
conversation.created_at,
|
||||
sla_policy.first_response_time_threshold.to_i,
|
||||
conversation.inbox,
|
||||
sla_policy
|
||||
)
|
||||
threshold = conversation.created_at.to_i + sla_policy.first_response_time_threshold.to_i
|
||||
return if first_reply_was_within_threshold?(conversation, threshold)
|
||||
return if still_within_threshold?(threshold)
|
||||
|
||||
@@ -90,12 +43,7 @@ class Sla::EvaluateAppliedSlaService
|
||||
# Waiting on customer response, no need to check next response time threshold
|
||||
return if conversation.waiting_since.blank?
|
||||
|
||||
threshold = calculate_threshold_deadline(
|
||||
conversation.waiting_since,
|
||||
sla_policy.next_response_time_threshold.to_i,
|
||||
conversation.inbox,
|
||||
sla_policy
|
||||
)
|
||||
threshold = conversation.waiting_since.to_i + sla_policy.next_response_time_threshold.to_i
|
||||
return if still_within_threshold?(threshold)
|
||||
|
||||
handle_missed_sla(applied_sla, 'nrt')
|
||||
@@ -113,12 +61,7 @@ class Sla::EvaluateAppliedSlaService
|
||||
def check_resolution_time_threshold(applied_sla, conversation, sla_policy)
|
||||
return if conversation.resolved?
|
||||
|
||||
threshold = calculate_threshold_deadline(
|
||||
conversation.created_at,
|
||||
sla_policy.resolution_time_threshold.to_i,
|
||||
conversation.inbox,
|
||||
sla_policy
|
||||
)
|
||||
threshold = conversation.created_at.to_i + sla_policy.resolution_time_threshold.to_i
|
||||
return if still_within_threshold?(threshold)
|
||||
|
||||
handle_missed_sla(applied_sla, 'rt')
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
class Voice::Provider::Twilio::Adapter
|
||||
def initialize(channel)
|
||||
@channel = channel
|
||||
end
|
||||
|
||||
def initiate_call(to:, conference_sid: nil, agent_id: nil)
|
||||
call = twilio_client.calls.create(**call_params(to))
|
||||
|
||||
{
|
||||
provider: 'twilio',
|
||||
call_sid: call.sid,
|
||||
status: call.status,
|
||||
call_direction: 'outbound',
|
||||
requires_agent_join: true,
|
||||
agent_id: agent_id,
|
||||
conference_sid: conference_sid
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def call_params(to)
|
||||
phone_digits = @channel.phone_number.delete_prefix('+')
|
||||
|
||||
{
|
||||
from: @channel.phone_number,
|
||||
to: to,
|
||||
url: twilio_call_twiml_url(phone_digits),
|
||||
status_callback: twilio_call_status_url(phone_digits),
|
||||
status_callback_event: %w[
|
||||
initiated ringing answered completed failed busy no-answer canceled
|
||||
],
|
||||
status_callback_method: 'POST'
|
||||
}
|
||||
end
|
||||
|
||||
def twilio_call_twiml_url(phone_digits)
|
||||
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: phone_digits)
|
||||
end
|
||||
|
||||
def twilio_call_status_url(phone_digits)
|
||||
Rails.application.routes.url_helpers.twilio_voice_status_url(phone: phone_digits)
|
||||
end
|
||||
|
||||
def twilio_client
|
||||
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
|
||||
end
|
||||
|
||||
def config
|
||||
@config ||= @channel.provider_config_hash
|
||||
end
|
||||
end
|
||||
@@ -1,46 +0,0 @@
|
||||
class Voice::Provider::Twilio::ConferenceService
|
||||
pattr_initialize [:conversation!, { twilio_client: nil }]
|
||||
|
||||
def ensure_conference_sid
|
||||
existing = conversation.additional_attributes&.dig('conference_sid')
|
||||
return existing if existing.present?
|
||||
|
||||
sid = Voice::Conference::Name.for(conversation)
|
||||
merge_attributes('conference_sid' => sid)
|
||||
sid
|
||||
end
|
||||
|
||||
def mark_agent_joined(user:)
|
||||
merge_attributes(
|
||||
'agent_joined' => true,
|
||||
'joined_at' => Time.current.to_i,
|
||||
'joined_by' => { id: user.id, name: user.name }
|
||||
)
|
||||
end
|
||||
|
||||
def end_conference
|
||||
twilio_client
|
||||
.conferences
|
||||
.list(friendly_name: Voice::Conference::Name.for(conversation), status: 'in-progress')
|
||||
.each { |conf| twilio_client.conferences(conf.sid).update(status: 'completed') }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def merge_attributes(attrs)
|
||||
current = conversation.additional_attributes || {}
|
||||
conversation.update!(additional_attributes: current.merge(attrs))
|
||||
end
|
||||
|
||||
def twilio_client
|
||||
@twilio_client ||= ::Twilio::REST::Client.new(account_sid, auth_token)
|
||||
end
|
||||
|
||||
def account_sid
|
||||
@account_sid ||= conversation.inbox.channel.provider_config_hash['account_sid']
|
||||
end
|
||||
|
||||
def auth_token
|
||||
@auth_token ||= conversation.inbox.channel.provider_config_hash['auth_token']
|
||||
end
|
||||
end
|
||||
@@ -1,62 +0,0 @@
|
||||
class Voice::Provider::Twilio::TokenService
|
||||
pattr_initialize [:inbox!, :user!, :account!]
|
||||
|
||||
def generate
|
||||
{
|
||||
token: access_token.to_jwt,
|
||||
identity: identity,
|
||||
voice_enabled: true,
|
||||
account_sid: config['account_sid'],
|
||||
agent_id: user.id,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
phone_number: inbox.channel.phone_number,
|
||||
twiml_endpoint: twiml_url,
|
||||
has_twiml_app: config['twiml_app_sid'].present?
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def config
|
||||
@config ||= inbox.channel.provider_config_hash || {}
|
||||
end
|
||||
|
||||
def identity
|
||||
@identity ||= "agent-#{user.id}-account-#{account.id}"
|
||||
end
|
||||
|
||||
def access_token
|
||||
Twilio::JWT::AccessToken.new(
|
||||
config['account_sid'],
|
||||
config['api_key_sid'],
|
||||
config['api_key_secret'],
|
||||
identity: identity,
|
||||
ttl: 1.hour.to_i
|
||||
).tap { |token| token.add_grant(voice_grant) }
|
||||
end
|
||||
|
||||
def voice_grant
|
||||
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
|
||||
grant.incoming_allow = true
|
||||
grant.outgoing_application_sid = config['twiml_app_sid']
|
||||
grant.outgoing_application_params = outgoing_params
|
||||
end
|
||||
end
|
||||
|
||||
def outgoing_params
|
||||
{
|
||||
account_id: account.id,
|
||||
agent_id: user.id,
|
||||
identity: identity,
|
||||
client_name: identity,
|
||||
accountSid: config['account_sid'],
|
||||
is_agent: 'true'
|
||||
}
|
||||
end
|
||||
|
||||
def twiml_url
|
||||
digits = inbox.channel.phone_number.delete_prefix('+')
|
||||
Rails.application.routes.url_helpers.twilio_voice_call_url(phone: digits)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
class Voice::Provider::TwilioAdapter
|
||||
def initialize(channel)
|
||||
@channel = channel
|
||||
end
|
||||
|
||||
def initiate_call(to:, _conference_sid: nil, _agent_id: nil)
|
||||
cfg = @channel.provider_config_hash
|
||||
|
||||
host = ENV.fetch('FRONTEND_URL')
|
||||
phone_digits = @channel.phone_number.delete_prefix('+')
|
||||
callback_url = "#{host}/twilio/voice/call/#{phone_digits}"
|
||||
|
||||
params = {
|
||||
from: @channel.phone_number,
|
||||
to: to,
|
||||
url: callback_url,
|
||||
status_callback: "#{host}/twilio/voice/status/#{phone_digits}",
|
||||
status_callback_event: %w[initiated ringing answered completed],
|
||||
status_callback_method: 'POST'
|
||||
}
|
||||
|
||||
call = twilio_client(cfg).calls.create(**params)
|
||||
|
||||
{ call_sid: call.sid }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def twilio_client(config)
|
||||
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,137 @@
|
||||
require 'openai'
|
||||
class Captain::Agent
|
||||
attr_reader :name, :tools, :prompt, :persona, :goal, :secrets
|
||||
|
||||
def initialize(name:, config:)
|
||||
@name = name
|
||||
@prompt = construct_prompt(config)
|
||||
@tools = prepare_tools(config[:tools] || [])
|
||||
@messages = config[:messages] || []
|
||||
@max_iterations = config[:max_iterations] || 10
|
||||
@llm = Captain::LlmService.new(api_key: config[:secrets][:OPENAI_API_KEY])
|
||||
@logger = Rails.logger
|
||||
|
||||
@logger.info(@prompt)
|
||||
end
|
||||
|
||||
def execute(input, context)
|
||||
setup_messages(input, context)
|
||||
result = {}
|
||||
@max_iterations.times do |iteration|
|
||||
push_to_messages(role: 'system', content: 'Provide a final answer') if iteration == @max_iterations - 1
|
||||
|
||||
result = @llm.call(@messages, functions)
|
||||
handle_llm_result(result)
|
||||
|
||||
break if result[:stop]
|
||||
end
|
||||
|
||||
result[:output]
|
||||
end
|
||||
|
||||
def register_tool(tool)
|
||||
@tools << tool
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_messages(input, context)
|
||||
if @messages.empty?
|
||||
push_to_messages({ role: 'system', content: @prompt })
|
||||
push_to_messages({ role: 'assistant', content: context }) if context.present?
|
||||
end
|
||||
push_to_messages({ role: 'user', content: input })
|
||||
end
|
||||
|
||||
def handle_llm_result(result)
|
||||
if result[:tool_call]
|
||||
tool_result = execute_tool(result[:tool_call])
|
||||
push_to_messages({ role: 'assistant', content: tool_result })
|
||||
else
|
||||
push_to_messages({ role: 'assistant', content: result[:output] })
|
||||
end
|
||||
result[:output]
|
||||
end
|
||||
|
||||
def execute_tool(tool_call)
|
||||
function_name = tool_call['function']['name']
|
||||
arguments = JSON.parse(tool_call['function']['arguments'])
|
||||
|
||||
tool = @tools.find { |t| t.name == function_name }
|
||||
tool.execute(arguments, {})
|
||||
rescue StandardError => e
|
||||
"Tool execution failed: #{e.message}"
|
||||
end
|
||||
|
||||
def construct_prompt(config)
|
||||
return config[:prompt] if config[:prompt]
|
||||
|
||||
<<~PROMPT
|
||||
Persona: #{config[:persona]}
|
||||
Objective: #{config[:goal]}
|
||||
|
||||
Guidelines:
|
||||
- Persistently work towards achieving the stated objective without deviation.
|
||||
- Use only the provided tools to complete the task. Avoid inventing or assuming function names.
|
||||
- Set `'stop': true` once the objective is fully achieved.
|
||||
- DO NOT return tool usage as the final result.
|
||||
- If sufficient information is available to deliver result, compile and present it to the user.
|
||||
- Always return a final result and ENSURE the final result is formatted in Markdown.
|
||||
|
||||
Output Structure:
|
||||
|
||||
1. **Tool Usage:**
|
||||
- If a relevant function is identified, call it directly without unnecessary explanations.
|
||||
|
||||
2. **Final Answer:**
|
||||
When ready to provide a complete response, follow this JSON format:
|
||||
|
||||
```json
|
||||
{
|
||||
"thought_process": "Explain the reasoning and steps taken to arrive at the final result.",
|
||||
"result": "Provide the complete response in clear, structured text.",
|
||||
"stop": true
|
||||
}
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def prepare_tools(tools = [])
|
||||
tools.map do |_, tool|
|
||||
Captain::Tool.new(
|
||||
name: tool['name'],
|
||||
config: {
|
||||
description: tool['description'],
|
||||
properties: tool['properties'],
|
||||
secrets: tool['secrets'],
|
||||
implementation: tool['implementation']
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def functions
|
||||
@tools.map do |tool|
|
||||
properties = {}
|
||||
tool.properties.each do |property_name, property_details|
|
||||
properties[property_name] = {
|
||||
type: property_details[:type],
|
||||
description: property_details[:description]
|
||||
}
|
||||
end
|
||||
required = tool.properties.select { |_, details| details[:required] == true }.keys
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: { type: 'object', properties: properties, required: required }
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def push_to_messages(message)
|
||||
@logger.info("\n\n\nMessage: #{message}\n\n\n")
|
||||
@messages << message
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
require 'openai'
|
||||
|
||||
class Captain::LlmService
|
||||
def initialize(config)
|
||||
@client = OpenAI::Client.new(
|
||||
access_token: config[:api_key],
|
||||
log_errors: Rails.env.development?
|
||||
)
|
||||
@logger = Rails.logger
|
||||
end
|
||||
|
||||
def call(messages, functions = [])
|
||||
openai_params = {
|
||||
model: 'gpt-4o',
|
||||
response_format: { type: 'json_object' },
|
||||
messages: messages
|
||||
}
|
||||
openai_params[:tools] = functions if functions.any?
|
||||
|
||||
response = @client.chat(parameters: openai_params)
|
||||
handle_response(response)
|
||||
rescue StandardError => e
|
||||
handle_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_response(response)
|
||||
if response['choices'][0]['message']['tool_calls']
|
||||
handle_tool_calls(response)
|
||||
else
|
||||
handle_direct_response(response)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_tool_calls(response)
|
||||
tool_call = response['choices'][0]['message']['tool_calls'][0]
|
||||
{
|
||||
tool_call: tool_call,
|
||||
output: nil,
|
||||
stop: false
|
||||
}
|
||||
end
|
||||
|
||||
def handle_direct_response(response)
|
||||
content = response.dig('choices', 0, 'message', 'content').strip
|
||||
parsed = JSON.parse(content)
|
||||
|
||||
{
|
||||
output: parsed['result'] || parsed['thought_process'],
|
||||
stop: parsed['stop'] || false
|
||||
}
|
||||
rescue JSON::ParserError => e
|
||||
handle_error(e, content)
|
||||
end
|
||||
|
||||
def handle_error(error, content = nil)
|
||||
@logger.error("LLM call failed: #{error.message}")
|
||||
@logger.error(error.backtrace.join("\n"))
|
||||
@logger.error("Content: #{content}") if content
|
||||
|
||||
{ output: 'Error occurred, retrying', stop: false }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
class Captain::Tool
|
||||
class InvalidImplementationError < StandardError; end
|
||||
class InvalidSecretsError < StandardError; end
|
||||
class ExecutionError < StandardError; end
|
||||
|
||||
REQUIRED_PROPERTIES = %w[name description properties secrets].freeze
|
||||
|
||||
attr_reader :name, :description, :properties, :secrets, :implementation, :memory
|
||||
|
||||
def initialize(name:, config:)
|
||||
@name = name
|
||||
@description = config[:description]
|
||||
@properties = config[:properties]
|
||||
@secrets = config[:secrets] || []
|
||||
@implementation = config[:implementation]
|
||||
@memory = config[:memory] || {}
|
||||
end
|
||||
|
||||
def register_method(&block)
|
||||
@implementation = block
|
||||
end
|
||||
|
||||
def execute(input, provided_secrets = {})
|
||||
validate_secrets!(provided_secrets)
|
||||
validate_input!(input)
|
||||
|
||||
raise ExecutionError, 'No implementation registered' unless @implementation
|
||||
|
||||
instance_exec(input, provided_secrets, memory, &@implementation)
|
||||
rescue StandardError => e
|
||||
raise ExecutionError, "Execution failed: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_config!(config)
|
||||
missing_keys = REQUIRED_PROPERTIES - config.keys
|
||||
return if missing_keys.empty?
|
||||
|
||||
raise InvalidImplementationError,
|
||||
"Missing required properties: #{missing_keys.join(', ')}"
|
||||
end
|
||||
|
||||
def validate_secrets!(provided_secrets)
|
||||
required_secrets = secrets.map!(&:to_sym)
|
||||
missing_secrets = required_secrets - provided_secrets.keys
|
||||
|
||||
return if missing_secrets.empty?
|
||||
|
||||
raise InvalidSecretsError, "Missing required secrets: #{missing_secrets.join(', ')}"
|
||||
end
|
||||
|
||||
def validate_input!(input)
|
||||
properties.each do |property, constraints|
|
||||
validate_property!(input, property, constraints)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_property!(input, property, constraints)
|
||||
value = input[property.to_sym]
|
||||
|
||||
raise ArgumentError, "Missing required property: #{property}" if constraints['required'] && value.nil?
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -36,13 +36,6 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
||||
|
||||
# Trigger the bot handoff (sets status to open + dispatches events)
|
||||
conversation.bot_handoff!
|
||||
|
||||
# Send out of office message if applicable (since template messages were suppressed while Captain was handling)
|
||||
send_out_of_office_message_if_applicable(conversation)
|
||||
end
|
||||
|
||||
def send_out_of_office_message_if_applicable(conversation)
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||
end
|
||||
|
||||
# TODO: Future enhancement - Add team assignment capability
|
||||
|
||||
@@ -11,11 +11,11 @@ class Captain::Tools::HttpTool < Agents::Tool
|
||||
@custom_tool.enabled?
|
||||
end
|
||||
|
||||
def perform(tool_context, **params)
|
||||
def perform(_tool_context, **params)
|
||||
url = @custom_tool.build_request_url(params)
|
||||
body = @custom_tool.build_request_body(params)
|
||||
|
||||
response = execute_http_request(url, body, tool_context)
|
||||
response = execute_http_request(url, body)
|
||||
@custom_tool.format_response(response.body)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
|
||||
@@ -39,7 +39,7 @@ class Captain::Tools::HttpTool < Agents::Tool
|
||||
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
|
||||
MAX_RESPONSE_SIZE = 1.megabyte
|
||||
|
||||
def execute_http_request(url, body, tool_context)
|
||||
def execute_http_request(url, body)
|
||||
uri = URI.parse(url)
|
||||
|
||||
# Check if resolved IP is private
|
||||
@@ -53,7 +53,6 @@ class Captain::Tools::HttpTool < Agents::Tool
|
||||
|
||||
request = build_http_request(uri, body)
|
||||
apply_authentication(request)
|
||||
apply_metadata_headers(request, tool_context)
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
@@ -103,10 +102,4 @@ class Captain::Tools::HttpTool < Agents::Tool
|
||||
credentials = @custom_tool.build_basic_auth_credentials
|
||||
request.basic_auth(*credentials) if credentials
|
||||
end
|
||||
|
||||
def apply_metadata_headers(request, tool_context)
|
||||
state = tool_context&.state || {}
|
||||
metadata_headers = @custom_tool.build_metadata_headers(state)
|
||||
metadata_headers.each { |key, value| request[key] = value }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
class Captain::Tools::ResolveTool < Captain::Tools::BasePublicTool
|
||||
description 'Mark the conversation as resolved when the issue has been addressed'
|
||||
param :reason, type: 'string', desc: 'The reason why the conversation is being resolved (optional)', required: false
|
||||
|
||||
def perform(tool_context, reason: nil)
|
||||
conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless conversation
|
||||
|
||||
return 'Conversation is already resolved' if conversation.resolved?
|
||||
|
||||
log_tool_usage('tool_resolve', {
|
||||
conversation_id: conversation.id,
|
||||
reason: reason || 'Agent resolved conversation'
|
||||
})
|
||||
|
||||
resolve_conversation(conversation, reason)
|
||||
|
||||
"Conversation marked as resolved#{" (Reason: #{reason})" if reason.present?}"
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
'Failed to resolve conversation'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_conversation(conversation, reason)
|
||||
# Add a private note with the reason if provided
|
||||
if reason.present?
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: @assistant,
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
content: "Resolved: #{reason}"
|
||||
)
|
||||
end
|
||||
|
||||
# Mark conversation as resolved
|
||||
Current.tool_name = 'resolve'
|
||||
conversation.resolved!
|
||||
ensure
|
||||
Current.tool_name = nil
|
||||
end
|
||||
|
||||
def permissions
|
||||
%w[conversation_manage conversation_unassigned_manage conversation_participating_manage]
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,7 @@ module Current
|
||||
thread_mattr_accessor :account_user
|
||||
thread_mattr_accessor :executed_by
|
||||
thread_mattr_accessor :contact
|
||||
thread_mattr_accessor :tool_name
|
||||
|
||||
def self.reset
|
||||
Current.user = nil
|
||||
@@ -11,5 +12,6 @@ module Current
|
||||
Current.account_user = nil
|
||||
Current.executed_by = nil
|
||||
Current.contact = nil
|
||||
Current.tool_name = nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,6 +7,14 @@ module Integrations::LlmInstrumentation
|
||||
include Integrations::LlmInstrumentationHelpers
|
||||
include Integrations::LlmInstrumentationSpans
|
||||
|
||||
PROVIDER_PREFIXES = {
|
||||
'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-],
|
||||
'anthropic' => %w[claude-],
|
||||
'google' => %w[gemini-],
|
||||
'mistral' => %w[mistral- codestral-],
|
||||
'deepseek' => %w[deepseek-]
|
||||
}.freeze
|
||||
|
||||
def instrument_llm_call(params)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
@@ -58,57 +66,16 @@ module Integrations::LlmInstrumentation
|
||||
end
|
||||
end
|
||||
|
||||
def instrument_embedding_call(params)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
def determine_provider(model_name)
|
||||
return 'openai' if model_name.blank?
|
||||
|
||||
instrument_with_span(params[:span_name] || 'llm.embedding', params) do |span, track_result|
|
||||
set_embedding_span_attributes(span, params)
|
||||
result = yield
|
||||
track_result.call(result)
|
||||
set_embedding_result_attributes(span, result)
|
||||
result
|
||||
model = model_name.to_s.downcase
|
||||
|
||||
PROVIDER_PREFIXES.each do |provider, prefixes|
|
||||
return provider if prefixes.any? { |prefix| model.start_with?(prefix) }
|
||||
end
|
||||
end
|
||||
|
||||
def instrument_audio_transcription(params)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
instrument_with_span(params[:span_name] || 'llm.audio.transcription', params) do |span, track_result|
|
||||
set_audio_transcription_span_attributes(span, params)
|
||||
result = yield
|
||||
track_result.call(result)
|
||||
set_transcription_result_attributes(span, result)
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
def instrument_moderation_call(params)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
instrument_with_span(params[:span_name] || 'llm.moderation', params) do |span, track_result|
|
||||
set_moderation_span_attributes(span, params)
|
||||
result = yield
|
||||
track_result.call(result)
|
||||
set_moderation_result_attributes(span, result)
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
def instrument_with_span(span_name, params, &)
|
||||
result = nil
|
||||
executed = false
|
||||
tracer.in_span(span_name) do |span|
|
||||
track_result = lambda do |r|
|
||||
executed = true
|
||||
result = r
|
||||
end
|
||||
yield(span, track_result)
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
|
||||
raise unless executed
|
||||
|
||||
result
|
||||
'openai'
|
||||
end
|
||||
|
||||
private
|
||||
@@ -119,4 +86,36 @@ module Integrations::LlmInstrumentation
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def setup_span_attributes(span, params)
|
||||
set_request_attributes(span, params)
|
||||
set_prompt_messages(span, params[:messages])
|
||||
set_metadata_attributes(span, params)
|
||||
end
|
||||
|
||||
def record_completion(span, result)
|
||||
if result.respond_to?(:content)
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, result.role.to_s) if result.respond_to?(:role)
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, result.content.to_s)
|
||||
elsif result.is_a?(Hash)
|
||||
set_completion_attributes(span, result) if result.is_a?(Hash)
|
||||
end
|
||||
end
|
||||
|
||||
def set_request_attributes(span, params)
|
||||
provider = determine_provider(params[:model])
|
||||
span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
|
||||
end
|
||||
|
||||
def set_prompt_messages(span, messages)
|
||||
messages.each_with_index do |msg, idx|
|
||||
role = msg[:role] || msg['role']
|
||||
content = msg[:content] || msg['content']
|
||||
|
||||
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), role)
|
||||
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Integrations::LlmInstrumentationCompletionHelpers
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
|
||||
private
|
||||
|
||||
def set_embedding_span_attributes(span, params)
|
||||
span.set_attribute(ATTR_GEN_AI_PROVIDER, determine_provider(params[:model]))
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
|
||||
span.set_attribute('embedding.input_length', params[:input]&.length || 0)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
|
||||
set_common_span_metadata(span, params)
|
||||
end
|
||||
|
||||
def set_audio_transcription_span_attributes(span, params)
|
||||
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'whisper-1')
|
||||
span.set_attribute('audio.duration_seconds', params[:duration]) if params[:duration]
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:file_path].to_s) if params[:file_path]
|
||||
set_common_span_metadata(span, params)
|
||||
end
|
||||
|
||||
def set_moderation_span_attributes(span, params)
|
||||
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'text-moderation-latest')
|
||||
span.set_attribute('moderation.input_length', params[:input]&.length || 0)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
|
||||
set_common_span_metadata(span, params)
|
||||
end
|
||||
|
||||
def set_common_span_metadata(span, params)
|
||||
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
|
||||
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) if params[:feature_name]
|
||||
end
|
||||
|
||||
def set_embedding_result_attributes(span, result)
|
||||
span.set_attribute('embedding.dimensions', result&.length || 0) if result.is_a?(Array)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, "[#{result&.length || 0} dimensions]")
|
||||
end
|
||||
|
||||
def set_transcription_result_attributes(span, result)
|
||||
transcribed_text = result.respond_to?(:text) ? result.text : result.to_s
|
||||
span.set_attribute('transcription.length', transcribed_text&.length || 0)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, transcribed_text.to_s)
|
||||
end
|
||||
|
||||
def set_moderation_result_attributes(span, result)
|
||||
span.set_attribute('moderation.flagged', result.flagged?) if result.respond_to?(:flagged?)
|
||||
span.set_attribute('moderation.categories', result.flagged_categories.to_json) if result.respond_to?(:flagged_categories)
|
||||
output = {
|
||||
flagged: result.respond_to?(:flagged?) ? result.flagged? : nil,
|
||||
categories: result.respond_to?(:flagged_categories) ? result.flagged_categories : []
|
||||
}
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, output.to_json)
|
||||
end
|
||||
|
||||
def set_completion_attributes(span, result)
|
||||
set_completion_message(span, result)
|
||||
set_usage_metrics(span, result)
|
||||
set_error_attributes(span, result)
|
||||
end
|
||||
|
||||
def set_completion_message(span, result)
|
||||
message = result[:message] || result.dig('choices', 0, 'message', 'content')
|
||||
return if message.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
|
||||
end
|
||||
|
||||
def set_usage_metrics(span, result)
|
||||
usage = result[:usage] || result['usage']
|
||||
return if usage.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
|
||||
end
|
||||
|
||||
def set_error_attributes(span, result)
|
||||
error = result[:error] || result['error']
|
||||
return if error.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
|
||||
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
|
||||
end
|
||||
end
|
||||
@@ -2,52 +2,38 @@
|
||||
|
||||
module Integrations::LlmInstrumentationHelpers
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
include Integrations::LlmInstrumentationCompletionHelpers
|
||||
|
||||
def determine_provider(model_name)
|
||||
return 'openai' if model_name.blank?
|
||||
|
||||
model = model_name.to_s.downcase
|
||||
|
||||
LlmConstants::PROVIDER_PREFIXES.each do |provider, prefixes|
|
||||
return provider if prefixes.any? { |prefix| model.start_with?(prefix) }
|
||||
end
|
||||
|
||||
'openai'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_span_attributes(span, params)
|
||||
set_request_attributes(span, params)
|
||||
set_prompt_messages(span, params[:messages])
|
||||
set_metadata_attributes(span, params)
|
||||
def set_completion_attributes(span, result)
|
||||
set_completion_message(span, result)
|
||||
set_usage_metrics(span, result)
|
||||
set_error_attributes(span, result)
|
||||
end
|
||||
|
||||
def record_completion(span, result)
|
||||
if result.respond_to?(:content)
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, result.role.to_s) if result.respond_to?(:role)
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, result.content.to_s)
|
||||
elsif result.is_a?(Hash)
|
||||
set_completion_attributes(span, result)
|
||||
end
|
||||
def set_completion_message(span, result)
|
||||
message = result[:message] || result.dig('choices', 0, 'message', 'content')
|
||||
return if message.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
|
||||
end
|
||||
|
||||
def set_request_attributes(span, params)
|
||||
provider = determine_provider(params[:model])
|
||||
span.set_attribute(ATTR_GEN_AI_PROVIDER, provider)
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
|
||||
def set_usage_metrics(span, result)
|
||||
usage = result[:usage] || result['usage']
|
||||
return if usage.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
|
||||
end
|
||||
|
||||
def set_prompt_messages(span, messages)
|
||||
messages.each_with_index do |msg, idx|
|
||||
role = msg[:role] || msg['role']
|
||||
content = msg[:content] || msg['content']
|
||||
def set_error_attributes(span, result)
|
||||
error = result[:error] || result['error']
|
||||
return if error.blank?
|
||||
|
||||
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), role)
|
||||
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s)
|
||||
end
|
||||
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
|
||||
span.status = OpenTelemetry::Trace::Status.error(error.to_s.truncate(1000))
|
||||
end
|
||||
|
||||
def set_metadata_attributes(span, params)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'opentelemetry_config'
|
||||
require_relative 'llm_instrumentation_constants'
|
||||
|
||||
module Integrations::LlmInstrumentationSpans
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
|
||||
@@ -77,22 +77,21 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
|
||||
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
|
||||
if valid_message?(message, character_count)
|
||||
add_message_to_list(message, messages, in_array_format)
|
||||
character_count += message.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
|
||||
def valid_message?(message, character_count)
|
||||
message.content.present? && character_count + message.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)
|
||||
def add_message_to_list(message, messages, in_array_format)
|
||||
formatted_message = format_message(message, in_array_format)
|
||||
messages.prepend(formatted_message)
|
||||
end
|
||||
|
||||
@@ -100,17 +99,17 @@ class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
|
||||
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)
|
||||
def format_message(message, in_array_format)
|
||||
in_array_format ? format_message_in_array(message) : format_message_in_string(message)
|
||||
end
|
||||
|
||||
def format_message_in_array(message, content)
|
||||
{ role: (message.incoming? ? 'user' : 'assistant'), content: content }
|
||||
def format_message_in_array(message)
|
||||
{ role: (message.incoming? ? 'user' : 'assistant'), content: message.content }
|
||||
end
|
||||
|
||||
def format_message_in_string(message, content)
|
||||
def format_message_in_string(message)
|
||||
sender_type = message.incoming? ? 'Customer' : 'Agent'
|
||||
"#{sender_type} #{message.sender&.name} : #{content}\n"
|
||||
"#{sender_type} #{message.sender&.name} : #{message.content}\n"
|
||||
end
|
||||
|
||||
def summarize_body
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module LlmConstants
|
||||
DEFAULT_MODEL = 'gpt-4.1-mini'
|
||||
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
|
||||
|
||||
OPENAI_API_ENDPOINT = 'https://api.openai.com'
|
||||
|
||||
PROVIDER_PREFIXES = {
|
||||
'openai' => %w[gpt- o1 o3 o4 text-embedding- whisper- tts-],
|
||||
'anthropic' => %w[claude-],
|
||||
'google' => %w[gemini-],
|
||||
'mistral' => %w[mistral- codestral-],
|
||||
'deepseek' => %w[deepseek-]
|
||||
}.freeze
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module OpenAiConstants
|
||||
DEFAULT_MODEL = 'gpt-4.1-mini'
|
||||
DEFAULT_ENDPOINT = 'https://api.openai.com'
|
||||
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||
PDF_PROCESSING_MODEL = 'gpt-4.1-mini'
|
||||
end
|
||||
@@ -1,100 +0,0 @@
|
||||
# Apply SLA Policy to Conversations
|
||||
#
|
||||
# This task applies an SLA policy to existing conversations that don't have one assigned.
|
||||
# It processes conversations in batches and only affects conversations with sla_policy_id = nil.
|
||||
#
|
||||
# Usage Examples:
|
||||
# # Using arguments (may need escaping in some shells)
|
||||
# bundle exec rake "sla:apply_to_conversations[19,1,500]"
|
||||
#
|
||||
# # Using environment variables (recommended)
|
||||
# SLA_POLICY_ID=19 ACCOUNT_ID=1 BATCH_SIZE=500 bundle exec rake sla:apply_to_conversations
|
||||
#
|
||||
# Parameters:
|
||||
# SLA_POLICY_ID: ID of the SLA policy to apply (required)
|
||||
# ACCOUNT_ID: ID of the account (required)
|
||||
# BATCH_SIZE: Number of conversations to process (default: 1000)
|
||||
#
|
||||
# Notes:
|
||||
# - Only runs in development environment
|
||||
# - Processes conversations in order of newest first (id DESC)
|
||||
# - Safe to run multiple times - skips conversations that already have SLA policies
|
||||
# - Creates AppliedSla records automatically via Rails callbacks
|
||||
# - SlaEvent records are created later by background jobs when violations occur
|
||||
#
|
||||
# rubocop:disable Metrics/BlockLength
|
||||
namespace :sla do
|
||||
desc 'Apply SLA policy to existing conversations'
|
||||
task :apply_to_conversations, [:sla_policy_id, :account_id, :batch_size] => :environment do |_t, args|
|
||||
unless Rails.env.development?
|
||||
puts 'This task can only be run in the development environment.'
|
||||
puts "Current environment: #{Rails.env}"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
sla_policy_id = args[:sla_policy_id] || ENV.fetch('SLA_POLICY_ID', nil)
|
||||
account_id = args[:account_id] || ENV.fetch('ACCOUNT_ID', nil)
|
||||
batch_size = (args[:batch_size] || ENV['BATCH_SIZE'] || 1000).to_i
|
||||
|
||||
if sla_policy_id.blank?
|
||||
puts 'Error: SLA_POLICY_ID is required'
|
||||
puts 'Usage: bundle exec rake sla:apply_to_conversations[sla_policy_id,account_id,batch_size]'
|
||||
puts 'Or: SLA_POLICY_ID=1 ACCOUNT_ID=1 BATCH_SIZE=500 bundle exec rake sla:apply_to_conversations'
|
||||
exit(1)
|
||||
end
|
||||
|
||||
if account_id.blank?
|
||||
puts 'Error: ACCOUNT_ID is required'
|
||||
puts 'Usage: bundle exec rake sla:apply_to_conversations[sla_policy_id,account_id,batch_size]'
|
||||
puts 'Or: SLA_POLICY_ID=1 ACCOUNT_ID=1 BATCH_SIZE=500 bundle exec rake sla:apply_to_conversations'
|
||||
exit(1)
|
||||
end
|
||||
|
||||
account = Account.find_by(id: account_id)
|
||||
unless account
|
||||
puts "Error: Account with ID #{account_id} not found"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
sla_policy = account.sla_policies.find_by(id: sla_policy_id)
|
||||
unless sla_policy
|
||||
puts "Error: SLA Policy with ID #{sla_policy_id} not found for Account #{account_id}"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
conversations = account.conversations.where(sla_policy_id: nil).order(id: :desc).limit(batch_size)
|
||||
total_count = conversations.count
|
||||
|
||||
if total_count.zero?
|
||||
puts 'No conversations found without SLA policy'
|
||||
exit(0)
|
||||
end
|
||||
|
||||
puts "Applying SLA Policy '#{sla_policy.name}' (ID: #{sla_policy_id}) to #{total_count} conversations in Account #{account_id}"
|
||||
puts "Processing in batches of #{batch_size}"
|
||||
puts "Started at: #{Time.current}"
|
||||
|
||||
start_time = Time.current
|
||||
processed_count = 0
|
||||
error_count = 0
|
||||
|
||||
conversations.find_in_batches(batch_size: batch_size) do |batch|
|
||||
batch.each do |conversation|
|
||||
conversation.update!(sla_policy_id: sla_policy_id)
|
||||
processed_count += 1
|
||||
puts "Processed #{processed_count}/#{total_count} conversations" if (processed_count % 100).zero?
|
||||
rescue StandardError => e
|
||||
error_count += 1
|
||||
puts "Error applying SLA to conversation #{conversation.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
elapsed_time = Time.current - start_time
|
||||
puts "\nCompleted!"
|
||||
puts "Successfully processed: #{processed_count} conversations"
|
||||
puts "Errors encountered: #{error_count}" if error_count.positive?
|
||||
puts "Total time: #{elapsed_time.round(2)}s"
|
||||
puts "Average time per conversation: #{(elapsed_time / processed_count).round(3)}s" if processed_count.positive?
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/BlockLength
|
||||
@@ -1,176 +0,0 @@
|
||||
# Generate Bulk Conversations
|
||||
#
|
||||
# This task creates bulk conversations with fake contacts and movie dialogue messages
|
||||
# for testing purposes. Each conversation gets random messages between contacts and agents.
|
||||
#
|
||||
# Usage Examples:
|
||||
# # Using arguments (may need escaping in some shells)
|
||||
# bundle exec rake "conversations:generate_bulk[100,1,1]"
|
||||
#
|
||||
# # Using environment variables (recommended)
|
||||
# COUNT=100 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk
|
||||
#
|
||||
# # Generate 50 conversations
|
||||
# COUNT=50 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk
|
||||
#
|
||||
# Parameters:
|
||||
# COUNT: Number of conversations to create (default: 10)
|
||||
# ACCOUNT_ID: ID of the account (required)
|
||||
# INBOX_ID: ID of the inbox that belongs to the account (required)
|
||||
#
|
||||
# What it creates:
|
||||
# - Unique contacts with fake names, emails, phone numbers
|
||||
# - Conversations with random status (open/resolved/pending)
|
||||
# - 3-10 messages per conversation with movie quotes
|
||||
# - Alternating incoming/outgoing message flow
|
||||
#
|
||||
# Notes:
|
||||
# - Only runs in development environment
|
||||
# - Creates realistic test data for conversation testing
|
||||
# - Progress shown every 10 conversations
|
||||
# - All contacts get unique email addresses to avoid conflicts
|
||||
#
|
||||
# rubocop:disable Metrics/BlockLength
|
||||
namespace :conversations do
|
||||
desc 'Generate bulk conversations with contacts and movie dialogue messages'
|
||||
task :generate_bulk, [:count, :account_id, :inbox_id] => :environment do |_t, args|
|
||||
unless Rails.env.development?
|
||||
puts 'This task can only be run in the development environment.'
|
||||
puts "Current environment: #{Rails.env}"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
count = (args[:count] || ENV['COUNT'] || 10).to_i
|
||||
account_id = args[:account_id] || ENV.fetch('ACCOUNT_ID', nil)
|
||||
inbox_id = args[:inbox_id] || ENV.fetch('INBOX_ID', nil)
|
||||
|
||||
if account_id.blank?
|
||||
puts 'Error: ACCOUNT_ID is required'
|
||||
puts 'Usage: bundle exec rake conversations:generate_bulk[count,account_id,inbox_id]'
|
||||
puts 'Or: COUNT=100 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk'
|
||||
exit(1)
|
||||
end
|
||||
|
||||
if inbox_id.blank?
|
||||
puts 'Error: INBOX_ID is required'
|
||||
puts 'Usage: bundle exec rake conversations:generate_bulk[count,account_id,inbox_id]'
|
||||
puts 'Or: COUNT=100 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk'
|
||||
exit(1)
|
||||
end
|
||||
|
||||
account = Account.find_by(id: account_id)
|
||||
inbox = Inbox.find_by(id: inbox_id)
|
||||
|
||||
unless account
|
||||
puts "Error: Account with ID #{account_id} not found"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
unless inbox
|
||||
puts "Error: Inbox with ID #{inbox_id} not found"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
unless inbox.account_id == account.id
|
||||
puts "Error: Inbox #{inbox_id} does not belong to Account #{account_id}"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
puts "Generating #{count} conversations for Account ##{account.id} in Inbox ##{inbox.id}..."
|
||||
puts "Started at: #{Time.current}"
|
||||
|
||||
start_time = Time.current
|
||||
created_count = 0
|
||||
|
||||
count.times do |i|
|
||||
contact = create_contact(account)
|
||||
contact_inbox = create_contact_inbox(contact, inbox)
|
||||
conversation = create_conversation(contact_inbox)
|
||||
add_messages(conversation)
|
||||
|
||||
created_count += 1
|
||||
puts "Created conversation #{i + 1}/#{count} (ID: #{conversation.id})" if ((i + 1) % 10).zero?
|
||||
rescue StandardError => e
|
||||
puts "Error creating conversation #{i + 1}: #{e.message}"
|
||||
puts e.backtrace.first(5).join("\n")
|
||||
end
|
||||
|
||||
elapsed_time = Time.current - start_time
|
||||
puts "\nCompleted!"
|
||||
puts "Successfully created: #{created_count} conversations"
|
||||
puts "Total time: #{elapsed_time.round(2)}s"
|
||||
puts "Average time per conversation: #{(elapsed_time / created_count).round(3)}s" if created_count.positive?
|
||||
end
|
||||
|
||||
def create_contact(account)
|
||||
Contact.create!(
|
||||
account: account,
|
||||
name: Faker::Name.name,
|
||||
email: "#{SecureRandom.uuid}@example.com",
|
||||
phone_number: generate_e164_phone_number,
|
||||
additional_attributes: {
|
||||
source: 'bulk_generator',
|
||||
company: Faker::Company.name,
|
||||
city: Faker::Address.city
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def generate_e164_phone_number
|
||||
country_code = [1, 44, 61, 91, 81].sample
|
||||
subscriber_number = rand(1_000_000..9_999_999_999).to_s
|
||||
subscriber_number = subscriber_number[0...(15 - country_code.to_s.length)]
|
||||
"+#{country_code}#{subscriber_number}"
|
||||
end
|
||||
|
||||
def create_contact_inbox(contact, inbox)
|
||||
ContactInboxBuilder.new(
|
||||
contact: contact,
|
||||
inbox: inbox
|
||||
).perform
|
||||
end
|
||||
|
||||
def create_conversation(contact_inbox)
|
||||
ConversationBuilder.new(
|
||||
params: ActionController::Parameters.new(
|
||||
status: %w[open resolved pending].sample,
|
||||
additional_attributes: {},
|
||||
custom_attributes: {}
|
||||
),
|
||||
contact_inbox: contact_inbox
|
||||
).perform
|
||||
end
|
||||
|
||||
def add_messages(conversation)
|
||||
num_messages = rand(3..10)
|
||||
message_type = %w[incoming outgoing].sample
|
||||
|
||||
num_messages.times do
|
||||
message_type = message_type == 'incoming' ? 'outgoing' : 'incoming'
|
||||
create_message(conversation, message_type)
|
||||
end
|
||||
end
|
||||
|
||||
def create_message(conversation, message_type)
|
||||
sender = if message_type == 'incoming'
|
||||
conversation.contact
|
||||
else
|
||||
conversation.account.users.sample || conversation.account.administrators.first
|
||||
end
|
||||
|
||||
conversation.messages.create!(
|
||||
account: conversation.account,
|
||||
inbox: conversation.inbox,
|
||||
sender: sender,
|
||||
message_type: message_type,
|
||||
content: generate_movie_dialogue,
|
||||
content_type: :text,
|
||||
private: false
|
||||
)
|
||||
end
|
||||
|
||||
def generate_movie_dialogue
|
||||
Faker::Movie.quote
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/BlockLength
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user