Merge branch 'develop' into feat/ui-lib
This commit is contained in:
+8
-1
@@ -7,6 +7,7 @@ plugins:
|
||||
require:
|
||||
- ./rubocop/use_from_email.rb
|
||||
- ./rubocop/custom_cop_location.rb
|
||||
- ./rubocop/attachment_download.rb
|
||||
- ./rubocop/one_class_per_file.rb
|
||||
|
||||
Layout/LineLength:
|
||||
@@ -41,6 +42,12 @@ Style/SymbolArray:
|
||||
Style/OpenStructUse:
|
||||
Enabled: false
|
||||
|
||||
Chatwoot/AttachmentDownload:
|
||||
Enabled: true
|
||||
Exclude:
|
||||
- 'spec/**/*'
|
||||
- 'test/**/*'
|
||||
|
||||
Style/OptionalBooleanParameter:
|
||||
Exclude:
|
||||
- 'app/services/email_templates/db_resolver_service.rb'
|
||||
@@ -88,7 +95,7 @@ Metrics/ModuleLength:
|
||||
Rails/HelperInstanceVariable:
|
||||
Exclude:
|
||||
- enterprise/app/helpers/captain/chat_helper.rb
|
||||
- 'enterprise/app/helpers/captain/tool_execution_helper.rb'
|
||||
- enterprise/app/helpers/captain/chat_response_helper.rb
|
||||
Rails/ApplicationController:
|
||||
Exclude:
|
||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||
|
||||
@@ -162,7 +162,7 @@ gem 'working_hours'
|
||||
gem 'pg_search'
|
||||
|
||||
# Subscriptions, Billing
|
||||
gem 'stripe'
|
||||
gem 'stripe', '~> 18.0'
|
||||
|
||||
## - helper gems --##
|
||||
## to populate db with sample data
|
||||
@@ -191,9 +191,10 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents', '>= 0.4.3'
|
||||
gem 'ai-agents', '>= 0.7.0'
|
||||
|
||||
# TODO: Move this gem as a dependency of ai-agents
|
||||
gem 'ruby_llm', '>= 1.8.2'
|
||||
gem 'ruby_llm-schema'
|
||||
|
||||
# OpenTelemetry for LLM observability
|
||||
@@ -214,7 +215,7 @@ group :production do
|
||||
end
|
||||
|
||||
group :development do
|
||||
gem 'annotate'
|
||||
gem 'annotaterb'
|
||||
gem 'bullet'
|
||||
gem 'letter_opener'
|
||||
gem 'scss_lint', require: false
|
||||
|
||||
+12
-11
@@ -126,11 +126,11 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.4.3)
|
||||
ruby_llm (~> 1.3)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
ai-agents (0.7.0)
|
||||
ruby_llm (~> 1.8.2)
|
||||
annotaterb (4.20.0)
|
||||
activerecord (>= 6.0.0)
|
||||
activesupport (>= 6.0.0)
|
||||
ast (2.4.3)
|
||||
attr_extras (7.1.0)
|
||||
audited (5.4.1)
|
||||
@@ -819,7 +819,7 @@ GEM
|
||||
ruby2ruby (2.5.0)
|
||||
ruby_parser (~> 3.1)
|
||||
sexp_processor (~> 4.6)
|
||||
ruby_llm (1.5.1)
|
||||
ruby_llm (1.8.2)
|
||||
base64
|
||||
event_stream_parser (~> 1)
|
||||
faraday (>= 1.10.0)
|
||||
@@ -828,7 +828,7 @@ GEM
|
||||
faraday-retry (>= 1)
|
||||
marcel (~> 1.0)
|
||||
zeitwerk (~> 2)
|
||||
ruby_llm-schema (0.1.0)
|
||||
ruby_llm-schema (0.2.5)
|
||||
ruby_parser (3.20.0)
|
||||
sexp_processor (~> 4.16)
|
||||
sass (3.7.4)
|
||||
@@ -928,7 +928,7 @@ GEM
|
||||
squasher (0.7.2)
|
||||
stackprof (0.2.25)
|
||||
statsd-ruby (1.5.0)
|
||||
stripe (8.5.0)
|
||||
stripe (18.0.1)
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
thor (1.4.0)
|
||||
@@ -1017,8 +1017,8 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.4.3)
|
||||
annotate
|
||||
ai-agents (>= 0.7.0)
|
||||
annotaterb
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
aws-actionmailbox-ses (~> 0)
|
||||
@@ -1119,6 +1119,7 @@ DEPENDENCIES
|
||||
rubocop-rails
|
||||
rubocop-rspec
|
||||
ruby-openai
|
||||
ruby_llm (>= 1.8.2)
|
||||
ruby_llm-schema
|
||||
scout_apm
|
||||
scss_lint
|
||||
@@ -1139,7 +1140,7 @@ DEPENDENCIES
|
||||
spring-watcher-listen
|
||||
squasher
|
||||
stackprof
|
||||
stripe
|
||||
stripe (~> 18.0)
|
||||
telephone_number
|
||||
test-prof
|
||||
tidewave
|
||||
|
||||
@@ -9,6 +9,8 @@ class Messages::Messenger::MessageBuilder
|
||||
attachment_obj.save!
|
||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
|
||||
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
|
||||
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
|
||||
update_attachment_file_type(attachment_obj)
|
||||
end
|
||||
|
||||
@@ -27,7 +29,7 @@ class Messages::Messenger::MessageBuilder
|
||||
file_type = attachment['type'].to_sym
|
||||
params = { file_type: file_type, account_id: @message.account_id }
|
||||
|
||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
|
||||
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel, :ig_post, :ig_story].include? file_type
|
||||
params.merge!(file_type_params(attachment))
|
||||
elsif file_type == :location
|
||||
params.merge!(location_params(attachment))
|
||||
@@ -39,9 +41,17 @@ class Messages::Messenger::MessageBuilder
|
||||
end
|
||||
|
||||
def file_type_params(attachment)
|
||||
# Handle different URL field names for different attachment types
|
||||
url = case attachment['type'].to_sym
|
||||
when :ig_story
|
||||
attachment['payload']['story_media_url']
|
||||
else
|
||||
attachment['payload']['url']
|
||||
end
|
||||
|
||||
{
|
||||
external_url: attachment['payload']['url'],
|
||||
remote_file_url: attachment['payload']['url']
|
||||
external_url: url,
|
||||
remote_file_url: url
|
||||
}
|
||||
end
|
||||
|
||||
@@ -68,6 +78,21 @@ class Messages::Messenger::MessageBuilder
|
||||
message.save!
|
||||
end
|
||||
|
||||
def fetch_ig_story_link(attachment)
|
||||
message = attachment.message
|
||||
# For ig_story, we don't have the same API call as story_mention, so we'll set it up similarly but with generic content
|
||||
message.content_attributes[:image_type] = 'ig_story'
|
||||
message.content = I18n.t('conversations.messages.instagram_shared_story_content')
|
||||
message.save!
|
||||
end
|
||||
|
||||
def fetch_ig_post_link(attachment)
|
||||
message = attachment.message
|
||||
message.content_attributes[:image_type] = 'ig_post'
|
||||
message.content = I18n.t('conversations.messages.instagram_shared_post_content')
|
||||
message.save!
|
||||
end
|
||||
|
||||
# This is a placeholder method to be overridden by child classes
|
||||
def get_story_object_from_source_id(_source_id)
|
||||
{}
|
||||
@@ -76,6 +101,6 @@ class Messages::Messenger::MessageBuilder
|
||||
private
|
||||
|
||||
def unsupported_file_type?(attachment_type)
|
||||
[:template, :unsupported_type].include? attachment_type.to_sym
|
||||
[:template, :unsupported_type, :ephemeral].include? attachment_type.to_sym
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,6 +23,10 @@ class EnterpriseAccountAPI extends ApiClient {
|
||||
action_type: action,
|
||||
});
|
||||
}
|
||||
|
||||
createTopupCheckout(credits) {
|
||||
return axios.post(`${this.url}topup_checkout`, { credits });
|
||||
}
|
||||
}
|
||||
|
||||
export default new EnterpriseAccountAPI();
|
||||
|
||||
@@ -11,6 +11,8 @@ describe('#enterpriseAccountAPI', () => {
|
||||
expect(accountAPI).toHaveProperty('delete');
|
||||
expect(accountAPI).toHaveProperty('checkout');
|
||||
expect(accountAPI).toHaveProperty('toggleDeletion');
|
||||
expect(accountAPI).toHaveProperty('createTopupCheckout');
|
||||
expect(accountAPI).toHaveProperty('getLimits');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
@@ -59,5 +61,29 @@ describe('#enterpriseAccountAPI', () => {
|
||||
{ action_type: 'undelete' }
|
||||
);
|
||||
});
|
||||
|
||||
it('#createTopupCheckout with credits', () => {
|
||||
accountAPI.createTopupCheckout(1000);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/enterprise/api/v1/topup_checkout',
|
||||
{ credits: 1000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('#createTopupCheckout with different credit amounts', () => {
|
||||
const creditAmounts = [1000, 2500, 6000, 12000];
|
||||
creditAmounts.forEach(credits => {
|
||||
accountAPI.createTopupCheckout(credits);
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/enterprise/api/v1/topup_checkout',
|
||||
{ credits }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('#getLimits', () => {
|
||||
accountAPI.getLimits();
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/enterprise/api/v1/limits');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,6 @@ const props = defineProps({
|
||||
},
|
||||
enableVariables: { type: Boolean, default: false },
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
enabledMenuOptions: { type: Array, default: () => [] },
|
||||
enableCaptainTools: { type: Boolean, default: false },
|
||||
signature: { type: String, default: '' },
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
@@ -102,7 +101,6 @@ watch(
|
||||
:disabled="disabled"
|
||||
:enable-variables="enableVariables"
|
||||
:enable-canned-responses="enableCannedResponses"
|
||||
:enabled-menu-options="enabledMenuOptions"
|
||||
:enable-captain-tools="enableCaptainTools"
|
||||
:signature="signature"
|
||||
:allow-signature="allowSignature"
|
||||
@@ -139,19 +137,6 @@ watch(
|
||||
.editor-wrapper {
|
||||
::v-deep {
|
||||
.ProseMirror-menubar-wrapper {
|
||||
@apply gap-2 !important;
|
||||
|
||||
.ProseMirror-menubar {
|
||||
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !top-0 !relative !important;
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply h-5 !important;
|
||||
}
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
|
||||
}
|
||||
}
|
||||
.ProseMirror.ProseMirror-woot-style {
|
||||
p {
|
||||
@apply first:mt-0 !important;
|
||||
|
||||
+1
-1
@@ -172,7 +172,7 @@ const previewArticle = () => {
|
||||
@apply mr-0;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0 mt-1 !mr-0;
|
||||
@apply p-0 mt-0 !mr-0;
|
||||
|
||||
svg {
|
||||
width: 20px !important;
|
||||
|
||||
@@ -299,7 +299,12 @@ const componentToRender = computed(() => {
|
||||
return DyteBubble;
|
||||
}
|
||||
|
||||
if (props.contentAttributes.imageType === 'story_mention') {
|
||||
const instagramSharedTypes = [
|
||||
ATTACHMENT_TYPES.STORY_MENTION,
|
||||
ATTACHMENT_TYPES.IG_STORY,
|
||||
ATTACHMENT_TYPES.IG_POST,
|
||||
];
|
||||
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
|
||||
return InstagramStoryBubble;
|
||||
}
|
||||
|
||||
@@ -476,7 +481,7 @@ provideMessageContext({
|
||||
<div
|
||||
v-if="shouldRenderMessage"
|
||||
:id="`message${props.id}`"
|
||||
class="flex w-full message-bubble-container mb-2"
|
||||
class="flex mb-2 w-full message-bubble-container"
|
||||
:data-message-id="props.id"
|
||||
:class="[
|
||||
flexOrientationClass,
|
||||
|
||||
@@ -49,6 +49,8 @@ export const ATTACHMENT_TYPES = {
|
||||
STORY_MENTION: 'story_mention',
|
||||
CONTACT: 'contact',
|
||||
IG_REEL: 'ig_reel',
|
||||
IG_POST: 'ig_post',
|
||||
IG_STORY: 'ig_story',
|
||||
};
|
||||
|
||||
export const CONTENT_TYPES = {
|
||||
|
||||
@@ -26,13 +26,11 @@ import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import {
|
||||
MESSAGE_EDITOR_MENU_OPTIONS,
|
||||
MESSAGE_EDITOR_IMAGE_RESIZES,
|
||||
} from 'dashboard/constants/editor';
|
||||
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
||||
|
||||
import {
|
||||
messageSchema,
|
||||
buildMessageSchema,
|
||||
buildEditor,
|
||||
EditorView,
|
||||
MessageMarkdownTransformer,
|
||||
@@ -53,6 +51,9 @@ import {
|
||||
removeSignature as removeSignatureHelper,
|
||||
scrollCursorIntoView,
|
||||
setURLWithQueryAndSize,
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
calculateMenuPosition,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
@@ -75,7 +76,6 @@ const props = defineProps({
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
enableCaptainTools: { type: Boolean, default: false },
|
||||
variables: { type: Object, default: () => ({}) },
|
||||
enabledMenuOptions: { type: Array, default: () => [] },
|
||||
signature: { type: String, default: '' },
|
||||
// allowSignature is a kill switch, ensuring no signature methods
|
||||
// are triggered except when this flag is true
|
||||
@@ -103,22 +103,34 @@ const { t } = useI18n();
|
||||
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
|
||||
const createState = (
|
||||
content,
|
||||
placeholder,
|
||||
plugins = [],
|
||||
methods = {},
|
||||
enabledMenuOptions = []
|
||||
) => {
|
||||
const editorSchema = computed(() => {
|
||||
if (!props.channelType) return messageSchema;
|
||||
|
||||
const formatType = props.isPrivate ? DEFAULT_FORMATTING : props.channelType;
|
||||
const formatting = getFormattingForEditor(formatType);
|
||||
return buildMessageSchema(formatting.marks, formatting.nodes);
|
||||
});
|
||||
|
||||
const editorMenuOptions = computed(() => {
|
||||
const formatType = props.isPrivate
|
||||
? DEFAULT_FORMATTING
|
||||
: props.channelType || DEFAULT_FORMATTING;
|
||||
const formatting = getFormattingForEditor(formatType);
|
||||
return formatting.menu;
|
||||
});
|
||||
|
||||
const createState = (content, placeholder, plugins = [], methods = {}) => {
|
||||
const schema = editorSchema.value;
|
||||
return EditorState.create({
|
||||
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
|
||||
doc: new MessageMarkdownTransformer(schema).parse(content),
|
||||
plugins: buildEditor({
|
||||
schema: messageSchema,
|
||||
schema,
|
||||
placeholder,
|
||||
methods,
|
||||
plugins,
|
||||
enabledMenuOptions,
|
||||
enabledMenuOptions: editorMenuOptions.value,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -153,6 +165,8 @@ const range = ref(null);
|
||||
const isImageNodeSelected = ref(false);
|
||||
const toolbarPosition = ref({ top: 0, left: 0 });
|
||||
const selectedImageNode = ref(null);
|
||||
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
const showSelectionMenu = ref(false);
|
||||
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
|
||||
|
||||
// element ref
|
||||
@@ -174,16 +188,6 @@ const shouldShowCannedResponses = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const editorMenuOptions = computed(() => {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
if (window.__WOOT_ISOLATED_SHELL__) {
|
||||
return [];
|
||||
}
|
||||
return props.enabledMenuOptions.length
|
||||
? props.enabledMenuOptions
|
||||
: MESSAGE_EDITOR_MENU_OPTIONS;
|
||||
});
|
||||
|
||||
function createSuggestionPlugin({
|
||||
trigger,
|
||||
minChars = 0,
|
||||
@@ -404,6 +408,38 @@ function setToolbarPosition() {
|
||||
};
|
||||
}
|
||||
|
||||
function setMenubarPosition({ selection } = {}) {
|
||||
const wrapper = editorRoot.value;
|
||||
if (!selection || !wrapper) return;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const isRtl = getComputedStyle(wrapper).direction === 'rtl';
|
||||
|
||||
// Calculate coords and final position
|
||||
const coords = getSelectionCoords(editorView, selection, rect);
|
||||
const { left, top, width } = calculateMenuPosition(coords, rect, isRtl);
|
||||
|
||||
wrapper.style.setProperty('--selection-left', `${left}px`);
|
||||
wrapper.style.setProperty(
|
||||
'--selection-right',
|
||||
`${rect.width - left - width}px`
|
||||
);
|
||||
wrapper.style.setProperty('--selection-top', `${top}px`);
|
||||
}
|
||||
|
||||
function checkSelection(editorState) {
|
||||
showSelectionMenu.value = false;
|
||||
const hasSelection = editorState.selection.from !== editorState.selection.to;
|
||||
if (hasSelection === isTextSelected.value) return;
|
||||
|
||||
isTextSelected.value = hasSelection;
|
||||
const wrapper = editorRoot.value;
|
||||
if (!wrapper) return;
|
||||
|
||||
wrapper.classList.toggle('has-selection', hasSelection);
|
||||
if (hasSelection) setMenubarPosition(editorState);
|
||||
}
|
||||
|
||||
function setURLWithQueryAndImageSize(size) {
|
||||
if (!props.showImageResizeToolbar) {
|
||||
return;
|
||||
@@ -537,7 +573,9 @@ async function insertNodeIntoEditor(node, from = 0, to = 0) {
|
||||
|
||||
function insertContentIntoEditor(content, defaultFrom = 0) {
|
||||
const from = defaultFrom || editorView.state.selection.from || 0;
|
||||
let node = new MessageMarkdownTransformer(messageSchema).parse(content);
|
||||
// Use the editor's current schema to ensure compatibility with buildMessageSchema
|
||||
const currentSchema = editorView.state.schema;
|
||||
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
|
||||
|
||||
insertNodeIntoEditor(node, from, undefined);
|
||||
}
|
||||
@@ -604,6 +642,7 @@ function createEditorView() {
|
||||
if (tx.docChanged) {
|
||||
emitOnChange();
|
||||
}
|
||||
checkSelection(state);
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keyup: () => {
|
||||
@@ -769,7 +808,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
@apply flex flex-col;
|
||||
@apply flex flex-col gap-3;
|
||||
|
||||
.ProseMirror-menubar:empty {
|
||||
display: none;
|
||||
@@ -777,11 +816,29 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
|
||||
.ProseMirror-menubar {
|
||||
min-height: 1.25rem !important;
|
||||
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
|
||||
@apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px];
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
@apply bg-n-slate-5 dark:bg-n-solid-3;
|
||||
@apply bg-n-slate-5 dark:bg-n-solid-3 !important;
|
||||
}
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply mr-0 size-4 flex items-center justify-center;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply size-4 flex items-center justify-center flex-shrink-0;
|
||||
|
||||
svg {
|
||||
@apply size-full;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar:not(:has(*)) {
|
||||
max-height: none !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
> .ProseMirror {
|
||||
@@ -872,4 +929,53 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
.editor-warning__message {
|
||||
@apply text-n-ruby-9 dark:text-n-ruby-9 font-normal text-sm pt-1 pb-0 px-0;
|
||||
}
|
||||
|
||||
// Float editor menu
|
||||
.popover-prosemirror-menu {
|
||||
position: relative;
|
||||
|
||||
.ProseMirror p:last-child {
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
display: none; // Hide by default
|
||||
}
|
||||
|
||||
&.has-selection {
|
||||
// Hide menu completely when it has no items
|
||||
.ProseMirror-menubar:not(:has(*)) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
|
||||
display: flex;
|
||||
width: fit-content !important;
|
||||
position: absolute !important;
|
||||
|
||||
// Default/LTR: position from left
|
||||
top: var(--selection-top);
|
||||
left: var(--selection-left);
|
||||
|
||||
// RTL: position from right instead
|
||||
[dir='rtl'] & {
|
||||
left: auto;
|
||||
right: var(--selection-right);
|
||||
}
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply mr-0 size-4 flex items-center;
|
||||
|
||||
.ProseMirror-icon {
|
||||
@apply p-0.5 flex-shrink-0;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
@apply bg-n-slate-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -78,10 +78,6 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showEditorToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isOnPrivateNote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -136,7 +132,6 @@ export default {
|
||||
emits: [
|
||||
'replaceText',
|
||||
'toggleInsertArticle',
|
||||
'toggleEditor',
|
||||
'selectWhatsappTemplate',
|
||||
'selectContentTemplate',
|
||||
'toggleQuotedReply',
|
||||
@@ -357,7 +352,25 @@ export default {
|
||||
/>
|
||||
</template>
|
||||
<NextButton
|
||||
v-if="allowSignature && showMessageSignatureButton"
|
||||
v-if="showAudioRecorderButton"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
|
||||
:icon="!isRecordingAudio ? 'i-ph-microphone' : 'i-ph-microphone-slash'"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="toggleAudioRecorder"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showAudioPlayStopButton"
|
||||
:icon="audioRecorderPlayStopIcon"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
:label="recordingAudioDurationText"
|
||||
@click="toggleAudioRecorderPlayPause"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showMessageSignatureButton"
|
||||
v-tooltip.top-end="signatureToggleTooltip"
|
||||
icon="i-ph-signature"
|
||||
slate
|
||||
|
||||
@@ -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,
|
||||
replaceSignature,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
@@ -61,7 +57,6 @@ export default {
|
||||
ArticleSearchPopover,
|
||||
AttachmentPreview,
|
||||
AudioRecorder,
|
||||
CannedResponse,
|
||||
ReplyBoxBanner,
|
||||
EmojiInput,
|
||||
MessageSignatureMissingAlert,
|
||||
@@ -69,7 +64,6 @@ export default {
|
||||
ReplyEmailHead,
|
||||
ReplyToMessage,
|
||||
ReplyTopPanel,
|
||||
ResizableTextArea,
|
||||
ContentTemplates,
|
||||
WhatsappTemplates,
|
||||
WootMessageEditor,
|
||||
@@ -86,7 +80,6 @@ export default {
|
||||
setup() {
|
||||
const {
|
||||
uiSettings,
|
||||
updateUISettings,
|
||||
isEditorHotKeyEnabled,
|
||||
fetchSignatureFlagFromUISettings,
|
||||
setQuotedReplyFlagForInbox,
|
||||
@@ -97,7 +90,6 @@ export default {
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
updateUISettings,
|
||||
isEditorHotKeyEnabled,
|
||||
fetchSignatureFlagFromUISettings,
|
||||
setQuotedReplyFlagForInbox,
|
||||
@@ -115,10 +107,7 @@ export default {
|
||||
isRecordingAudio: false,
|
||||
recordingAudioState: '',
|
||||
recordingAudioDurationText: '',
|
||||
isUploading: false,
|
||||
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||
mentionSearchKey: '',
|
||||
hasSlashCommand: false,
|
||||
bccEmails: '',
|
||||
ccEmails: '',
|
||||
toEmails: '',
|
||||
@@ -159,20 +148,6 @@ export default {
|
||||
!this.is360DialogWhatsAppChannel
|
||||
);
|
||||
},
|
||||
showRichContentEditor() {
|
||||
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.isAPIInbox) {
|
||||
const {
|
||||
display_rich_content_editor: displayRichContentEditor = false,
|
||||
} = this.uiSettings;
|
||||
return displayRichContentEditor;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
showWhatsappTemplates() {
|
||||
// We support templates for API channels if someone updates templates manually via API
|
||||
// That's why we don't explicitly check for channel type here
|
||||
@@ -300,9 +275,6 @@ export default {
|
||||
hasAttachments() {
|
||||
return this.attachedFiles.length;
|
||||
},
|
||||
isRichEditorEnabled() {
|
||||
return this.isAWebWidgetInbox || this.isAnEmailChannel;
|
||||
},
|
||||
showAudioRecorder() {
|
||||
return !this.isOnPrivateNote && this.showFileUpload;
|
||||
},
|
||||
@@ -342,21 +314,11 @@ export default {
|
||||
return !this.isPrivate && this.sendWithSignature;
|
||||
},
|
||||
isSignatureAvailable() {
|
||||
return !!this.signatureToApply;
|
||||
return !!this.messageSignature;
|
||||
},
|
||||
sendWithSignature() {
|
||||
return this.fetchSignatureFlagFromUISettings(this.channelType);
|
||||
},
|
||||
editorMessageKey() {
|
||||
const { editor_message_key: isEnabled } = this.uiSettings;
|
||||
return isEnabled;
|
||||
},
|
||||
commandPlusEnterToSendEnabled() {
|
||||
return this.editorMessageKey === 'cmd_enter';
|
||||
},
|
||||
enterToSendEnabled() {
|
||||
return this.editorMessageKey === 'enter';
|
||||
},
|
||||
conversationId() {
|
||||
return this.currentChat.id;
|
||||
},
|
||||
@@ -383,12 +345,6 @@ export default {
|
||||
});
|
||||
return variables;
|
||||
},
|
||||
// ensure that the signature is plain text depending on `showRichContentEditor`
|
||||
signatureToApply() {
|
||||
return this.showRichContentEditor
|
||||
? this.messageSignature
|
||||
: extractTextFromMarkdown(this.messageSignature);
|
||||
},
|
||||
connectedPortalSlug() {
|
||||
const { help_center: portal = {} } = this.inbox;
|
||||
const { slug = '' } = portal;
|
||||
@@ -481,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();
|
||||
},
|
||||
@@ -512,7 +450,7 @@ export default {
|
||||
mounted() {
|
||||
this.getFromDraft();
|
||||
// Don't use the keyboard listener mixin here as the events here are supposed to be
|
||||
// working even if input/textarea is focussed.
|
||||
// working even if the editor is focussed.
|
||||
document.addEventListener('paste', this.onPaste);
|
||||
document.addEventListener('keydown', this.handleKeyEvents);
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
@@ -549,45 +487,17 @@ 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);
|
||||
},
|
||||
toggleRichContentEditor() {
|
||||
this.updateUISettings({
|
||||
display_rich_content_editor: !this.showRichContentEditor,
|
||||
});
|
||||
|
||||
const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
|
||||
|
||||
if (!this.showRichContentEditor && this.messageSignature) {
|
||||
// remove the old signature -> extract text from markdown -> attach new signature
|
||||
let message = removeSignature(this.message, this.messageSignature);
|
||||
message = extractTextFromMarkdown(message);
|
||||
message = appendSignature(message, plainTextSignature);
|
||||
|
||||
this.message = message;
|
||||
} else {
|
||||
this.message = replaceSignature(
|
||||
this.message,
|
||||
plainTextSignature,
|
||||
this.messageSignature
|
||||
);
|
||||
}
|
||||
},
|
||||
toggleQuotedReply() {
|
||||
if (!this.isAnEmailChannel) {
|
||||
return;
|
||||
@@ -655,8 +565,8 @@ export default {
|
||||
}
|
||||
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.signatureToApply)
|
||||
: removeSignature(message, this.signatureToApply);
|
||||
? appendSignature(message, this.messageSignature)
|
||||
: removeSignature(message, this.messageSignature);
|
||||
},
|
||||
removeFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
@@ -672,7 +582,6 @@ export default {
|
||||
Escape: {
|
||||
action: () => {
|
||||
this.hideEmojiPicker();
|
||||
this.hideMentions();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
@@ -715,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;
|
||||
}
|
||||
@@ -851,7 +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
|
||||
message = appendSignature(message, this.signatureToApply);
|
||||
message = appendSignature(message, this.messageSignature);
|
||||
}
|
||||
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
@@ -875,40 +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
|
||||
this.message = appendSignature(this.message, this.signatureToApply);
|
||||
this.message = appendSignature(this.message, this.messageSignature);
|
||||
}
|
||||
this.attachedFiles = [];
|
||||
this.isRecordingAudio = false;
|
||||
@@ -926,19 +814,15 @@ export default {
|
||||
},
|
||||
toggleAudioRecorder() {
|
||||
this.isRecordingAudio = !this.isRecordingAudio;
|
||||
this.isRecorderAudioStopped = !this.isRecordingAudio;
|
||||
if (!this.isRecordingAudio) {
|
||||
this.resetAudioRecorderInput();
|
||||
}
|
||||
},
|
||||
toggleAudioRecorderPlayPause() {
|
||||
if (!this.isRecordingAudio) {
|
||||
return;
|
||||
}
|
||||
if (!this.isRecorderAudioStopped) {
|
||||
this.isRecorderAudioStopped = true;
|
||||
if (!this.$refs.audioRecorderInput) return;
|
||||
if (!this.recordingAudioState) {
|
||||
this.$refs.audioRecorderInput.stopRecording();
|
||||
} else if (this.isRecorderAudioStopped) {
|
||||
} else {
|
||||
this.$refs.audioRecorderInput.playPause();
|
||||
}
|
||||
},
|
||||
@@ -947,9 +831,6 @@ export default {
|
||||
this.toggleEmojiPicker();
|
||||
}
|
||||
},
|
||||
hideMentions() {
|
||||
this.showMentions = false;
|
||||
},
|
||||
onTypingOn() {
|
||||
this.toggleTyping('on');
|
||||
},
|
||||
@@ -1196,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"
|
||||
@@ -1226,33 +1100,17 @@ 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"
|
||||
class="input popover-prosemirror-menu"
|
||||
:is-private="isOnPrivateNote"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:update-selection-with="updateEditorSelectionWith"
|
||||
:min-height="4"
|
||||
enable-variables
|
||||
:variables="messageVariables"
|
||||
:signature="signatureToApply"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:channel-type="channelType"
|
||||
@typing-off="onTypingOff"
|
||||
@@ -1302,7 +1160,6 @@ export default {
|
||||
:recording-audio-state="recordingAudioState"
|
||||
:send-button-text="replyButtonLabel"
|
||||
:show-audio-recorder="showAudioRecorder"
|
||||
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
|
||||
:show-emoji-picker="showEmojiPicker"
|
||||
:show-file-upload="showFileUpload"
|
||||
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
|
||||
@@ -1321,7 +1178,6 @@ export default {
|
||||
allow-file-upload
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@toggle-editor="toggleRichContentEditor"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@toggle-quoted-reply="toggleQuotedReply"
|
||||
@@ -1375,10 +1231,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 {
|
||||
@@ -1398,9 +1250,4 @@ export default {
|
||||
@apply ltr:left-1 rtl:right-1 -bottom-2;
|
||||
}
|
||||
}
|
||||
|
||||
.normal-editor__canned-box {
|
||||
width: calc(100% - 2 * 1rem);
|
||||
left: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store.js';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
@@ -9,6 +9,7 @@ export function useCaptain() {
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled, currentAccount } = useAccount();
|
||||
const { isEnterprise } = useConfig();
|
||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||
|
||||
const captainEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
|
||||
@@ -34,6 +35,8 @@ export function useCaptain() {
|
||||
return null;
|
||||
});
|
||||
|
||||
const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits);
|
||||
|
||||
const fetchLimits = () => {
|
||||
if (isEnterprise) {
|
||||
store.dispatch('accounts/limits');
|
||||
@@ -46,5 +49,6 @@ export function useCaptain() {
|
||||
documentLimits,
|
||||
responseLimits,
|
||||
fetchLimits,
|
||||
isFetchingLimits,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +1,143 @@
|
||||
export const MESSAGE_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
'link',
|
||||
'undo',
|
||||
'redo',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'code',
|
||||
];
|
||||
|
||||
export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
'link',
|
||||
'undo',
|
||||
'redo',
|
||||
'imageUpload',
|
||||
];
|
||||
// Formatting rules for different contexts (channels and special contexts)
|
||||
// marks: inline formatting (strong, em, code, link, strike)
|
||||
// nodes: block structures (bulletList, orderedList, codeBlock, blockquote)
|
||||
export const FORMATTING = {
|
||||
// Channel formatting
|
||||
'Channel::Email': {
|
||||
marks: ['strong', 'em', 'code', 'link'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
||||
menu: [
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'link',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::WebWidget': {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
||||
menu: [
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'link',
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::Api': {
|
||||
marks: ['strong', 'em'],
|
||||
nodes: [],
|
||||
menu: ['strong', 'em', 'undo', 'redo'],
|
||||
},
|
||||
'Channel::FacebookPage': {
|
||||
marks: ['strong', 'em', 'code', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock'],
|
||||
menu: [
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::TwitterProfile': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::TwilioSms': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::Sms': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::Whatsapp': {
|
||||
marks: ['strong', 'em', 'code', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock'],
|
||||
menu: [
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::Line': {
|
||||
marks: ['strong', 'em', 'code', 'strike'],
|
||||
nodes: ['codeBlock'],
|
||||
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
|
||||
},
|
||||
'Channel::Telegram': {
|
||||
marks: ['strong', 'em', 'link', 'code'],
|
||||
nodes: [],
|
||||
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
|
||||
},
|
||||
'Channel::Instagram': {
|
||||
marks: ['strong', 'em', 'code', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList'],
|
||||
menu: [
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'strike',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::Voice': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
// Special contexts (not actual channels)
|
||||
'Context::Default': {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
||||
menu: [
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'link',
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Context::MessageSignature': {
|
||||
marks: ['strong', 'em', 'link'],
|
||||
nodes: [],
|
||||
menu: ['strong', 'em', 'link', 'undo', 'redo', 'imageUpload'],
|
||||
},
|
||||
'Context::InboxSettings': {
|
||||
marks: ['strong', 'em', 'link'],
|
||||
nodes: [],
|
||||
menu: ['strong', 'em', 'link', 'undo', 'redo'],
|
||||
},
|
||||
};
|
||||
|
||||
// Editor menu options for Full Editor
|
||||
export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
@@ -33,14 +153,81 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||
'code',
|
||||
];
|
||||
|
||||
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
'link',
|
||||
'undo',
|
||||
'redo',
|
||||
/**
|
||||
* Markdown formatting patterns for stripping unsupported formatting.
|
||||
*
|
||||
* Maps camelCase type names to ProseMirror snake_case schema names.
|
||||
* Order matters: codeBlock before code to avoid partial matches.
|
||||
*/
|
||||
export const MARKDOWN_PATTERNS = [
|
||||
// --- BLOCK NODES ---
|
||||
{
|
||||
type: 'codeBlock', // PM: code_block, eg: ```js\ncode\n```
|
||||
patterns: [
|
||||
{ pattern: /`{3}(?:\w+)?\n?([\s\S]*?)`{3}/g, replacement: '$1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'blockquote', // PM: blockquote, eg: > quote
|
||||
patterns: [{ pattern: /^> ?/gm, replacement: '' }],
|
||||
},
|
||||
{
|
||||
type: 'bulletList', // PM: bullet_list, eg: - item
|
||||
patterns: [{ pattern: /^[\t ]*[-*+]\s+/gm, replacement: '' }],
|
||||
},
|
||||
{
|
||||
type: 'orderedList', // PM: ordered_list, eg: 1. item
|
||||
patterns: [{ pattern: /^[\t ]*\d+\.\s+/gm, replacement: '' }],
|
||||
},
|
||||
{
|
||||
type: 'heading', // PM: heading, eg: ## Heading
|
||||
patterns: [{ pattern: /^#{1,6}\s+/gm, replacement: '' }],
|
||||
},
|
||||
{
|
||||
type: 'horizontalRule', // PM: horizontal_rule, eg: ---
|
||||
patterns: [{ pattern: /^(?:---|___|\*\*\*)\s*$/gm, replacement: '' }],
|
||||
},
|
||||
{
|
||||
type: 'image', // PM: image, eg: 
|
||||
patterns: [{ pattern: /!\[([^\]]*)\]\([^)]+\)/g, replacement: '$1' }],
|
||||
},
|
||||
{
|
||||
type: 'hardBreak', // PM: hard_break, eg: line\\\n or line \n
|
||||
patterns: [
|
||||
{ pattern: /\\\n/g, replacement: '\n' },
|
||||
{ pattern: / {2,}\n/g, replacement: '\n' },
|
||||
],
|
||||
},
|
||||
// --- INLINE MARKS ---
|
||||
{
|
||||
type: 'strong', // PM: strong, eg: **bold** or __bold__
|
||||
patterns: [
|
||||
{ pattern: /\*\*(.+?)\*\*/g, replacement: '$1' },
|
||||
{ pattern: /__(.+?)__/g, replacement: '$1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'em', // PM: em, eg: *italic* or _italic_
|
||||
patterns: [
|
||||
{ pattern: /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, replacement: '$1' },
|
||||
{ pattern: /(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, replacement: '$1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'strike', // PM: strike, eg: ~~strikethrough~~
|
||||
patterns: [{ pattern: /~~(.+?)~~/g, replacement: '$1' }],
|
||||
},
|
||||
{
|
||||
type: 'code', // PM: code, eg: `inline code`
|
||||
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
|
||||
},
|
||||
{
|
||||
type: 'link', // PM: link, eg: [text](url)
|
||||
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
|
||||
},
|
||||
];
|
||||
|
||||
// Editor image resize options for Message Editor
|
||||
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
|
||||
{
|
||||
name: 'Small',
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
/**
|
||||
* The delimiter used to separate the signature from the rest of the body.
|
||||
@@ -283,6 +285,47 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips unsupported markdown formatting from content based on the editor schema.
|
||||
* This ensures canned responses with rich formatting can be inserted into channels
|
||||
* that don't support certain formatting (e.g., API channels don't support bold).
|
||||
*
|
||||
* @param {string} content - The markdown content to sanitize
|
||||
* @param {Object} schema - The ProseMirror schema with supported marks and nodes
|
||||
* @returns {string} - Content with unsupported formatting stripped
|
||||
*/
|
||||
export function stripUnsupportedFormatting(content, schema) {
|
||||
if (!content || typeof content !== 'string') return content;
|
||||
if (!schema) return content;
|
||||
|
||||
let sanitizedContent = content;
|
||||
|
||||
// Get supported marks and nodes from the schema
|
||||
// Note: ProseMirror uses snake_case internally (code_block, bullet_list, etc.)
|
||||
// but our FORMATTING constant uses camelCase (codeBlock, bulletList, etc.)
|
||||
// We use camelcase-keys to normalize node names for comparison
|
||||
const supportedMarks = Object.keys(schema.marks || {});
|
||||
const nodeKeys = Object.keys(schema.nodes || {});
|
||||
const nodeKeysObj = Object.fromEntries(nodeKeys.map(k => [k, true]));
|
||||
const supportedNodes = Object.keys(camelcaseKeys(nodeKeysObj));
|
||||
|
||||
// Process each formatting type in order (codeBlock before code is important!)
|
||||
MARKDOWN_PATTERNS.forEach(({ type, patterns }) => {
|
||||
// Check if this format type is supported by the schema
|
||||
const isMarkSupported = supportedMarks.includes(type);
|
||||
const isNodeSupported = supportedNodes.includes(type);
|
||||
|
||||
// If not supported, strip the formatting
|
||||
if (!isMarkSupported && !isNodeSupported) {
|
||||
patterns.forEach(({ pattern, replacement }) => {
|
||||
sanitizedContent = sanitizedContent.replace(pattern, replacement);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return sanitizedContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content Node Creation Helper Functions for
|
||||
* - mention
|
||||
@@ -313,8 +356,17 @@ const createNode = (editorView, nodeType, content) => {
|
||||
|
||||
return mentionNode;
|
||||
}
|
||||
case 'cannedResponse':
|
||||
return new MessageMarkdownTransformer(messageSchema).parse(content);
|
||||
case 'cannedResponse': {
|
||||
// Strip unsupported formatting before parsing to ensure content can be inserted
|
||||
// into channels that don't support certain markdown features (e.g., API channels)
|
||||
const sanitizedContent = stripUnsupportedFormatting(
|
||||
content,
|
||||
state.schema
|
||||
);
|
||||
return new MessageMarkdownTransformer(state.schema).parse(
|
||||
sanitizedContent
|
||||
);
|
||||
}
|
||||
case 'variable':
|
||||
return state.schema.text(`{{${content}}}`);
|
||||
case 'emoji':
|
||||
@@ -389,3 +441,85 @@ export const getContentNode = (
|
||||
? creator(editorView, content, from, to, variables)
|
||||
: { node: null, from, to };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the formatting configuration for a specific channel type.
|
||||
* Returns the appropriate marks, nodes, and menu items for the editor.
|
||||
*
|
||||
* @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
|
||||
* @returns {Object} The formatting configuration with marks, nodes, and menu properties
|
||||
*/
|
||||
export function getFormattingForEditor(channelType) {
|
||||
return FORMATTING[channelType] || FORMATTING['Context::Default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu Positioning Helpers
|
||||
* Handles floating menu bar positioning for text selection in the editor.
|
||||
*/
|
||||
|
||||
const MENU_CONFIG = { H: 46, W: 300, GAP: 10 };
|
||||
|
||||
/**
|
||||
* Calculate selection coordinates with bias to handle line-wraps correctly.
|
||||
* @param {EditorView} editorView - ProseMirror editor view
|
||||
* @param {Selection} selection - Current text selection
|
||||
* @param {DOMRect} rect - Container bounding rect
|
||||
* @returns {{start: Object, end: Object, selTop: number, onTop: boolean}}
|
||||
*/
|
||||
export function getSelectionCoords(editorView, selection, rect) {
|
||||
const start = editorView.coordsAtPos(selection.from, 1);
|
||||
const end = editorView.coordsAtPos(selection.to, -1);
|
||||
|
||||
const selTop = Math.min(start.top, end.top);
|
||||
const spaceAbove = selTop - rect.top;
|
||||
const onTop =
|
||||
spaceAbove > MENU_CONFIG.H + MENU_CONFIG.GAP || end.bottom > rect.bottom;
|
||||
|
||||
return { start, end, selTop, onTop };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate anchor position based on selection visibility and RTL direction.
|
||||
* @param {Object} coords - Selection coordinates from getSelectionCoords
|
||||
* @param {DOMRect} rect - Container bounding rect
|
||||
* @param {boolean} isRtl - Whether text direction is RTL
|
||||
* @returns {number} Anchor x-position for menu
|
||||
*/
|
||||
export function getMenuAnchor(coords, rect, isRtl) {
|
||||
const { start, end, onTop } = coords;
|
||||
|
||||
if (!onTop) return end.left;
|
||||
|
||||
// If start of selection is visible, align to text. Else stick to container edge.
|
||||
if (start.top >= rect.top) return isRtl ? start.right : start.left;
|
||||
|
||||
return isRtl ? rect.right - MENU_CONFIG.GAP : rect.left + MENU_CONFIG.GAP;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate final menu position (left, top) within container bounds.
|
||||
* @param {Object} coords - Selection coordinates from getSelectionCoords
|
||||
* @param {DOMRect} rect - Container bounding rect
|
||||
* @param {boolean} isRtl - Whether text direction is RTL
|
||||
* @returns {{left: number, top: number, width: number}}
|
||||
*/
|
||||
export function calculateMenuPosition(coords, rect, isRtl) {
|
||||
const { start, end, selTop, onTop } = coords;
|
||||
|
||||
const anchor = getMenuAnchor(coords, rect, isRtl);
|
||||
|
||||
// Calculate Left: shift by width if RTL, then make relative to container
|
||||
const rawLeft = (isRtl ? anchor - MENU_CONFIG.W : anchor) - rect.left;
|
||||
|
||||
// Ensure menu stays within container bounds
|
||||
const left = Math.min(Math.max(0, rawLeft), rect.width - MENU_CONFIG.W);
|
||||
|
||||
// Calculate Top: align to selection or bottom of selection
|
||||
const top = onTop
|
||||
? Math.max(-26, selTop - rect.top - MENU_CONFIG.H - MENU_CONFIG.GAP)
|
||||
: Math.max(start.bottom, end.bottom) - rect.top + MENU_CONFIG.GAP;
|
||||
return { left, top, width: MENU_CONFIG.W };
|
||||
}
|
||||
|
||||
/* End Menu Positioning Helpers */
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
|
||||
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
|
||||
import { getContentNode } from '../editorHelper';
|
||||
import {
|
||||
MessageMarkdownTransformer,
|
||||
messageSchema,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
|
||||
vi.mock('@chatwoot/prosemirror-schema', () => ({
|
||||
MessageMarkdownTransformer: vi.fn(),
|
||||
messageSchema: {},
|
||||
}));
|
||||
|
||||
vi.mock('@chatwoot/utils', () => ({
|
||||
@@ -62,12 +58,18 @@ describe('getContentNode', () => {
|
||||
const to = 10;
|
||||
const updatedMessage = 'Hello John';
|
||||
|
||||
replaceVariablesInMessage.mockReturnValue(updatedMessage);
|
||||
MessageMarkdownTransformer.mockImplementation(() => ({
|
||||
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
|
||||
}));
|
||||
// Mock the node that will be returned by parse
|
||||
const mockNode = { textContent: updatedMessage };
|
||||
|
||||
const { node } = getContentNode(
|
||||
replaceVariablesInMessage.mockReturnValue(updatedMessage);
|
||||
|
||||
// Mock MessageMarkdownTransformer instance with parse method
|
||||
const mockTransformer = {
|
||||
parse: vi.fn().mockReturnValue(mockNode),
|
||||
};
|
||||
MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
|
||||
|
||||
const result = getContentNode(
|
||||
editorView,
|
||||
'cannedResponse',
|
||||
content,
|
||||
@@ -79,8 +81,15 @@ describe('getContentNode', () => {
|
||||
message: content,
|
||||
variables,
|
||||
});
|
||||
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
|
||||
expect(node.textContent).toBe(updatedMessage);
|
||||
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
|
||||
editorView.state.schema
|
||||
);
|
||||
expect(mockTransformer.parse).toHaveBeenCalledWith(updatedMessage);
|
||||
expect(result.node).toBe(mockNode);
|
||||
expect(result.node.textContent).toBe(updatedMessage);
|
||||
// When textContent matches updatedMessage, from should remain unchanged
|
||||
expect(result.from).toBe(from);
|
||||
expect(result.to).toBe(to);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,13 @@ import {
|
||||
findNodeToInsertImage,
|
||||
setURLWithQueryAndSize,
|
||||
getContentNode,
|
||||
getFormattingForEditor,
|
||||
getSelectionCoords,
|
||||
getMenuAnchor,
|
||||
calculateMenuPosition,
|
||||
stripUnsupportedFormatting,
|
||||
} from '../editorHelper';
|
||||
import { FORMATTING } from 'dashboard/constants/editor';
|
||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||
import { EditorView } from '@chatwoot/prosemirror-schema';
|
||||
import { Schema } from 'prosemirror-model';
|
||||
@@ -258,15 +264,11 @@ describe('insertAtCursor', () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should unwrap doc nodes that are wrapped in a paragraph', () => {
|
||||
const docNode = schema.node('doc', null, [
|
||||
schema.node('paragraph', null, [schema.text('Hello')]),
|
||||
]);
|
||||
|
||||
it('should insert text node at cursor position', () => {
|
||||
const editorState = createEditorState();
|
||||
const editorView = new EditorView(document.body, { state: editorState });
|
||||
|
||||
insertAtCursor(editorView, docNode, 0);
|
||||
insertAtCursor(editorView, schema.text('Hello'), 0);
|
||||
|
||||
// Check if node was unwrapped and inserted correctly
|
||||
expect(editorView.state.doc.firstChild.firstChild.text).toBe('Hello');
|
||||
@@ -626,3 +628,329 @@ describe('getContentNode', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFormattingForEditor', () => {
|
||||
describe('channel-specific formatting', () => {
|
||||
it('returns full formatting for Email channel', () => {
|
||||
const result = getFormattingForEditor('Channel::Email');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::Email']);
|
||||
});
|
||||
|
||||
it('returns full formatting for WebWidget channel', () => {
|
||||
const result = getFormattingForEditor('Channel::WebWidget');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::WebWidget']);
|
||||
});
|
||||
|
||||
it('returns limited formatting for WhatsApp channel', () => {
|
||||
const result = getFormattingForEditor('Channel::Whatsapp');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
|
||||
});
|
||||
|
||||
it('returns no formatting for API channel', () => {
|
||||
const result = getFormattingForEditor('Channel::Api');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::Api']);
|
||||
});
|
||||
|
||||
it('returns limited formatting for FacebookPage channel', () => {
|
||||
const result = getFormattingForEditor('Channel::FacebookPage');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
|
||||
});
|
||||
|
||||
it('returns no formatting for TwitterProfile channel', () => {
|
||||
const result = getFormattingForEditor('Channel::TwitterProfile');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
|
||||
});
|
||||
|
||||
it('returns no formatting for SMS channel', () => {
|
||||
const result = getFormattingForEditor('Channel::Sms');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::Sms']);
|
||||
});
|
||||
|
||||
it('returns limited formatting for Telegram channel', () => {
|
||||
const result = getFormattingForEditor('Channel::Telegram');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::Telegram']);
|
||||
});
|
||||
|
||||
it('returns formatting for Instagram channel', () => {
|
||||
const result = getFormattingForEditor('Channel::Instagram');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Channel::Instagram']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('context-specific formatting', () => {
|
||||
it('returns default formatting for Context::Default', () => {
|
||||
const result = getFormattingForEditor('Context::Default');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||
});
|
||||
|
||||
it('returns signature formatting for Context::MessageSignature', () => {
|
||||
const result = getFormattingForEditor('Context::MessageSignature');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Context::MessageSignature']);
|
||||
});
|
||||
|
||||
it('returns widget builder formatting for Context::InboxSettings', () => {
|
||||
const result = getFormattingForEditor('Context::InboxSettings');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Context::InboxSettings']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback behavior', () => {
|
||||
it('returns default formatting for unknown channel type', () => {
|
||||
const result = getFormattingForEditor('Channel::Unknown');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||
});
|
||||
|
||||
it('returns default formatting for null channel type', () => {
|
||||
const result = getFormattingForEditor(null);
|
||||
|
||||
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||
});
|
||||
|
||||
it('returns default formatting for undefined channel type', () => {
|
||||
const result = getFormattingForEditor(undefined);
|
||||
|
||||
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||
});
|
||||
|
||||
it('returns default formatting for empty string', () => {
|
||||
const result = getFormattingForEditor('');
|
||||
|
||||
expect(result).toEqual(FORMATTING['Context::Default']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('return value structure', () => {
|
||||
it('always returns an object with marks, nodes, and menu properties', () => {
|
||||
const result = getFormattingForEditor('Channel::Email');
|
||||
|
||||
expect(result).toHaveProperty('marks');
|
||||
expect(result).toHaveProperty('nodes');
|
||||
expect(result).toHaveProperty('menu');
|
||||
expect(Array.isArray(result.marks)).toBe(true);
|
||||
expect(Array.isArray(result.nodes)).toBe(true);
|
||||
expect(Array.isArray(result.menu)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUnsupportedFormatting', () => {
|
||||
describe('when schema supports all formatting', () => {
|
||||
const fullSchema = {
|
||||
marks: { strong: {}, em: {}, code: {}, strike: {}, link: {} },
|
||||
nodes: { bulletList: {}, orderedList: {}, codeBlock: {}, blockquote: {} },
|
||||
};
|
||||
|
||||
it('preserves all formatting when schema supports it', () => {
|
||||
const content = '**bold** and *italic* and `code`';
|
||||
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||
});
|
||||
|
||||
it('preserves links when schema supports them', () => {
|
||||
const content = 'Check [this link](https://example.com)';
|
||||
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||
});
|
||||
|
||||
it('preserves lists when schema supports them', () => {
|
||||
const content = '- item 1\n- item 2\n1. first\n2. second';
|
||||
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when schema has no formatting support (eg:SMS channel)', () => {
|
||||
const emptySchema = {
|
||||
marks: {},
|
||||
nodes: {},
|
||||
};
|
||||
|
||||
it('strips bold formatting', () => {
|
||||
expect(stripUnsupportedFormatting('**bold text**', emptySchema)).toBe(
|
||||
'bold text'
|
||||
);
|
||||
expect(stripUnsupportedFormatting('__bold text__', emptySchema)).toBe(
|
||||
'bold text'
|
||||
);
|
||||
});
|
||||
|
||||
it('strips italic formatting', () => {
|
||||
expect(stripUnsupportedFormatting('*italic text*', emptySchema)).toBe(
|
||||
'italic text'
|
||||
);
|
||||
expect(stripUnsupportedFormatting('_italic text_', emptySchema)).toBe(
|
||||
'italic text'
|
||||
);
|
||||
});
|
||||
|
||||
it('strips inline code formatting', () => {
|
||||
expect(stripUnsupportedFormatting('`inline code`', emptySchema)).toBe(
|
||||
'inline code'
|
||||
);
|
||||
});
|
||||
|
||||
it('strips strikethrough formatting', () => {
|
||||
expect(stripUnsupportedFormatting('~~strikethrough~~', emptySchema)).toBe(
|
||||
'strikethrough'
|
||||
);
|
||||
});
|
||||
|
||||
it('strips links but keeps text', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting(
|
||||
'Check [this link](https://example.com)',
|
||||
emptySchema
|
||||
)
|
||||
).toBe('Check this link');
|
||||
});
|
||||
|
||||
it('strips bullet list markers', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
|
||||
).toBe('item 1\nitem 2');
|
||||
expect(
|
||||
stripUnsupportedFormatting('* item 1\n* item 2', emptySchema)
|
||||
).toBe('item 1\nitem 2');
|
||||
});
|
||||
|
||||
it('strips ordered list markers', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting('1. first\n2. second', emptySchema)
|
||||
).toBe('first\nsecond');
|
||||
});
|
||||
|
||||
it('strips code block markers', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting('```javascript\ncode here\n```', emptySchema)
|
||||
).toBe('code here\n');
|
||||
});
|
||||
|
||||
it('strips blockquote markers', () => {
|
||||
expect(stripUnsupportedFormatting('> quoted text', emptySchema)).toBe(
|
||||
'quoted text'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles complex content with multiple formatting types', () => {
|
||||
const content =
|
||||
'**Bold** and *italic* with `code` and [link](url)\n- list item';
|
||||
const expected = 'Bold and italic with code and link\nlist item';
|
||||
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when schema has partial support', () => {
|
||||
const partialSchema = {
|
||||
marks: { strong: {}, em: {} },
|
||||
nodes: {},
|
||||
};
|
||||
|
||||
it('preserves supported marks and strips unsupported ones', () => {
|
||||
const content = '**bold** and `code`';
|
||||
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
|
||||
'**bold** and code'
|
||||
);
|
||||
});
|
||||
|
||||
it('strips unsupported nodes but keeps supported marks', () => {
|
||||
const content = '**bold** text\n- list item';
|
||||
expect(stripUnsupportedFormatting(content, partialSchema)).toBe(
|
||||
'**bold** text\nlist item'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('returns content unchanged if content is empty', () => {
|
||||
expect(stripUnsupportedFormatting('', {})).toBe('');
|
||||
});
|
||||
|
||||
it('returns content unchanged if content is null', () => {
|
||||
expect(stripUnsupportedFormatting(null, {})).toBe(null);
|
||||
});
|
||||
|
||||
it('returns content unchanged if content is undefined', () => {
|
||||
expect(stripUnsupportedFormatting(undefined, {})).toBe(undefined);
|
||||
});
|
||||
|
||||
it('returns content unchanged if schema is null', () => {
|
||||
expect(stripUnsupportedFormatting('**bold**', null)).toBe('**bold**');
|
||||
});
|
||||
|
||||
it('handles nested formatting correctly', () => {
|
||||
const emptySchema = { marks: {}, nodes: {} };
|
||||
// After stripping bold (**), the remaining *and italic* becomes italic and is stripped too
|
||||
expect(
|
||||
stripUnsupportedFormatting('**bold *and italic***', emptySchema)
|
||||
).toBe('bold and italic');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Menu positioning helpers', () => {
|
||||
const mockEditorView = {
|
||||
coordsAtPos: vi.fn((pos, bias) => {
|
||||
// Return different coords based on position
|
||||
if (bias === 1) return { top: 100, bottom: 120, left: 50, right: 100 };
|
||||
return { top: 100, bottom: 120, left: 150, right: 200 };
|
||||
}),
|
||||
};
|
||||
|
||||
const wrapperRect = { top: 50, bottom: 300, left: 0, right: 400, width: 400 };
|
||||
|
||||
describe('getSelectionCoords', () => {
|
||||
it('returns selection coordinates with onTop flag', () => {
|
||||
const selection = { from: 0, to: 10 };
|
||||
const result = getSelectionCoords(mockEditorView, selection, wrapperRect);
|
||||
|
||||
expect(result).toHaveProperty('start');
|
||||
expect(result).toHaveProperty('end');
|
||||
expect(result).toHaveProperty('selTop');
|
||||
expect(result).toHaveProperty('onTop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMenuAnchor', () => {
|
||||
it('returns end.left when menu is below selection', () => {
|
||||
const coords = { start: { left: 50 }, end: { left: 150 }, onTop: false };
|
||||
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(150);
|
||||
});
|
||||
|
||||
it('returns start.left for LTR when menu is above and visible', () => {
|
||||
const coords = { start: { top: 100, left: 50 }, end: {}, onTop: true };
|
||||
expect(getMenuAnchor(coords, wrapperRect, false)).toBe(50);
|
||||
});
|
||||
|
||||
it('returns start.right for RTL when menu is above and visible', () => {
|
||||
const coords = { start: { top: 100, right: 100 }, end: {}, onTop: true };
|
||||
expect(getMenuAnchor(coords, wrapperRect, true)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateMenuPosition', () => {
|
||||
it('returns bounded left and top positions', () => {
|
||||
const coords = {
|
||||
start: { top: 100, bottom: 120, left: 50 },
|
||||
end: { top: 100, bottom: 120, left: 150 },
|
||||
selTop: 100,
|
||||
onTop: false,
|
||||
};
|
||||
const result = calculateMenuPosition(coords, wrapperRect, false);
|
||||
|
||||
expect(result).toHaveProperty('left');
|
||||
expect(result).toHaveProperty('top');
|
||||
expect(result).toHaveProperty('width', 300);
|
||||
expect(result.left).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,7 +196,6 @@
|
||||
"INSERT_READ_MORE": "Read more",
|
||||
"DISMISS_REPLY": "Dismiss reply",
|
||||
"REPLYING_TO": "Replying to:",
|
||||
"TIP_FORMAT_ICON": "Show rich text editor",
|
||||
"TIP_EMOJI_ICON": "Show emoji selector",
|
||||
"TIP_ATTACH_ICON": "Attach files",
|
||||
"TIP_AUDIORECORDER_ICON": "Record audio",
|
||||
|
||||
@@ -399,15 +399,39 @@
|
||||
"DESCRIPTION": "Manage usage and credits for Captain AI.",
|
||||
"BUTTON_TXT": "Buy more credits",
|
||||
"DOCUMENTS": "Documents",
|
||||
"RESPONSES": "Responses",
|
||||
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more."
|
||||
"RESPONSES": "Credits",
|
||||
"UPGRADE": "Captain is not available on the free plan, upgrade now to get access to assistants, copilot and more.",
|
||||
"REFRESH_CREDITS": "Refresh"
|
||||
},
|
||||
"CHAT_WITH_US": {
|
||||
"TITLE": "Need help?",
|
||||
"DESCRIPTION": "Do you face any issues in billing? We are here to help.",
|
||||
"BUTTON_TXT": "Chat with us"
|
||||
},
|
||||
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
|
||||
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again.",
|
||||
"TOPUP": {
|
||||
"BUY_CREDITS": "Buy more credits",
|
||||
"MODAL_TITLE": "Buy AI Credits",
|
||||
"MODAL_DESCRIPTION": "Purchase additional credits for Captain AI.",
|
||||
"CREDITS": "CREDITS",
|
||||
"ONE_TIME": "one-time",
|
||||
"POPULAR": "Most Popular",
|
||||
"NOTE_TITLE": "Note:",
|
||||
"NOTE_DESCRIPTION": "Credits are added immediately and expire in 6 months. An active subscription is required to use credits. Purchased credits are consumed after your monthly plan credits.",
|
||||
"CANCEL": "Cancel",
|
||||
"PURCHASE": "Purchase Credits",
|
||||
"LOADING": "Loading options...",
|
||||
"FETCH_ERROR": "Failed to load credit options. Please try again.",
|
||||
"PURCHASE_ERROR": "Failed to process purchase. Please try again.",
|
||||
"PURCHASE_SUCCESS": "Successfully added {credits} credits to your account",
|
||||
"CONFIRM": {
|
||||
"TITLE": "Confirm Purchase",
|
||||
"DESCRIPTION": "You are about to purchase {credits} credits for {amount}.",
|
||||
"INSTANT_DEDUCTION_NOTE": "Your saved card will be charged immediately upon confirmation.",
|
||||
"GO_BACK": "Go Back",
|
||||
"CONFIRM_PURCHASE": "Confirm Purchase"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SECURITY_SETTINGS": {
|
||||
"TITLE": "Security",
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
useFunctionGetter,
|
||||
useStore,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
|
||||
import ContactConversations from './ContactConversations.vue';
|
||||
@@ -52,12 +54,22 @@ const isShopifyFeatureEnabled = computed(
|
||||
() => shopifyIntegration.value.enabled
|
||||
);
|
||||
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const isLinearFeatureEnabled = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR)
|
||||
);
|
||||
|
||||
const linearIntegration = useFunctionGetter(
|
||||
'integrations/getIntegration',
|
||||
'linear'
|
||||
);
|
||||
|
||||
const isLinearIntegrationEnabled = computed(
|
||||
const isLinearClientIdConfigured = computed(() => {
|
||||
return !!linearIntegration.value?.id;
|
||||
});
|
||||
|
||||
const isLinearConnected = computed(
|
||||
() => linearIntegration.value?.enabled || false
|
||||
);
|
||||
|
||||
@@ -238,7 +250,13 @@ onMounted(() => {
|
||||
<MacrosList :conversation-id="conversationId" />
|
||||
</AccordionItem>
|
||||
</woot-feature-toggle>
|
||||
<div v-else-if="element.name === 'linear_issues'">
|
||||
<div
|
||||
v-else-if="
|
||||
element.name === 'linear_issues' &&
|
||||
isLinearFeatureEnabled &&
|
||||
isLinearClientIdConfigured
|
||||
"
|
||||
>
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.LINEAR_ISSUES')"
|
||||
:is-open="isContactSidebarItemOpen('is_linear_issues_open')"
|
||||
@@ -247,7 +265,7 @@ onMounted(() => {
|
||||
value => toggleSidebarUIState('is_linear_issues_open', value)
|
||||
"
|
||||
>
|
||||
<LinearSetupCTA v-if="!isLinearIntegrationEnabled" />
|
||||
<LinearSetupCTA v-if="!isLinearConnected" />
|
||||
<LinearIssuesList v-else :conversation-id="conversationId" />
|
||||
</AccordionItem>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import BillingMeter from './components/BillingMeter.vue';
|
||||
import BillingCard from './components/BillingCard.vue';
|
||||
import BillingHeader from './components/BillingHeader.vue';
|
||||
import DetailItem from './components/DetailItem.vue';
|
||||
import PurchaseCreditsModal from './components/PurchaseCreditsModal.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
@@ -23,6 +24,7 @@ const {
|
||||
documentLimits,
|
||||
responseLimits,
|
||||
fetchLimits,
|
||||
isFetchingLimits,
|
||||
} = useCaptain();
|
||||
|
||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||
@@ -32,6 +34,7 @@ const BILLING_REFRESH_ATTEMPTED = 'billing_refresh_attempted';
|
||||
|
||||
// State for handling refresh attempts and loading
|
||||
const isWaitingForBilling = ref(false);
|
||||
const purchaseCreditsModalRef = ref(null);
|
||||
|
||||
const customAttributes = computed(() => {
|
||||
return currentAccount.value.custom_attributes || {};
|
||||
@@ -45,6 +48,11 @@ const planName = computed(() => {
|
||||
return customAttributes.value.plan_name;
|
||||
});
|
||||
|
||||
const canPurchaseCredits = computed(() => {
|
||||
const plan = planName.value?.toLowerCase();
|
||||
return plan && plan !== 'hacker';
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed property for subscribed quantity
|
||||
* @returns {number|undefined}
|
||||
@@ -71,8 +79,9 @@ const hasABillingPlan = computed(() => {
|
||||
const fetchAccountDetails = async () => {
|
||||
if (!hasABillingPlan.value) {
|
||||
await store.dispatch('accounts/subscription');
|
||||
fetchLimits();
|
||||
}
|
||||
// Always fetch limits for billing page to show credit usage
|
||||
fetchLimits();
|
||||
};
|
||||
|
||||
const handleBillingPageLogic = async () => {
|
||||
@@ -119,6 +128,15 @@ const onToggleChatWindow = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openPurchaseCreditsModal = () => {
|
||||
purchaseCreditsModalRef.value?.open();
|
||||
};
|
||||
|
||||
const handleTopupSuccess = () => {
|
||||
// Refresh limits to show updated credit balance
|
||||
fetchLimits();
|
||||
};
|
||||
|
||||
onMounted(handleBillingPageLogic);
|
||||
</script>
|
||||
|
||||
@@ -178,9 +196,27 @@ onMounted(handleBillingPageLogic);
|
||||
:description="$t('BILLING_SETTINGS.CAPTAIN.DESCRIPTION')"
|
||||
>
|
||||
<template #action>
|
||||
<ButtonV4 sm faded slate disabled>
|
||||
{{ $t('BILLING_SETTINGS.CAPTAIN.BUTTON_TXT') }}
|
||||
</ButtonV4>
|
||||
<div class="flex gap-2">
|
||||
<ButtonV4
|
||||
sm
|
||||
flushed
|
||||
slate
|
||||
icon="i-lucide-refresh-cw"
|
||||
:is-loading="isFetchingLimits"
|
||||
@click="fetchLimits"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.CAPTAIN.REFRESH_CREDITS') }}
|
||||
</ButtonV4>
|
||||
<ButtonV4
|
||||
v-if="canPurchaseCredits"
|
||||
sm
|
||||
solid
|
||||
blue
|
||||
@click="openPurchaseCreditsModal"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.TOPUP.BUY_CREDITS') }}
|
||||
</ButtonV4>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="captainLimits && responseLimits" class="px-5">
|
||||
<BillingMeter
|
||||
@@ -223,6 +259,10 @@ onMounted(handleBillingPageLogic);
|
||||
</ButtonV4>
|
||||
</BillingHeader>
|
||||
</section>
|
||||
<PurchaseCreditsModal
|
||||
ref="purchaseCreditsModalRef"
|
||||
@success="handleTopupSuccess"
|
||||
/>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
credits: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
amount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
currency: {
|
||||
type: String,
|
||||
default: 'usd',
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isPopular: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select']);
|
||||
|
||||
const formatCredits = credits => {
|
||||
return credits.toLocaleString();
|
||||
};
|
||||
|
||||
const formatAmount = (amount, currency) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label
|
||||
class="relative flex flex-col p-6 border-2 rounded-xl transition-all cursor-pointer bg-n-solid-1 hover:bg-n-solid-2"
|
||||
:class="[
|
||||
isSelected ? 'border-woot-500' : 'border-n-weak hover:border-n-strong',
|
||||
]"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
:name="name"
|
||||
:value="credits"
|
||||
:checked="isSelected"
|
||||
class="sr-only"
|
||||
@change="emit('select')"
|
||||
/>
|
||||
<span
|
||||
v-if="isPopular"
|
||||
class="absolute -top-3 left-4 px-3 py-1 text-xs font-medium rounded"
|
||||
:class="
|
||||
isSelected ? 'bg-woot-500 text-white' : 'bg-n-solid-3 text-n-slate-11'
|
||||
"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.TOPUP.POPULAR') }}
|
||||
</span>
|
||||
<div
|
||||
v-if="isSelected"
|
||||
class="absolute top-4 right-4 flex items-center justify-center w-6 h-6 rounded-full bg-woot-500"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-3xl font-normal text-n-slate-12 mb-2 tracking-tighter">
|
||||
{{ formatCredits(credits) }}
|
||||
</span>
|
||||
<span
|
||||
class="text-xs font-normal text-n-slate-11 uppercase tracking-tight mb-6"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.TOPUP.CREDITS') }}
|
||||
</span>
|
||||
<span class="text-2xl font-normal text-n-slate-12 tracking-tight">
|
||||
{{ formatAmount(amount, currency) }}
|
||||
<span class="text-sm text-n-slate-11 ml-0.5">{{
|
||||
$t('BILLING_SETTINGS.TOPUP.ONE_TIME')
|
||||
}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CreditPackageCard from './CreditPackageCard.vue';
|
||||
import EnterpriseAccountAPI from 'dashboard/api/enterprise/account';
|
||||
|
||||
const emit = defineEmits(['close', 'success']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const TOPUP_OPTIONS = [
|
||||
{ credits: 1000, amount: 20.0, currency: 'usd' },
|
||||
{ credits: 2500, amount: 50.0, currency: 'usd' },
|
||||
{ credits: 6000, amount: 100.0, currency: 'usd' },
|
||||
{ credits: 12000, amount: 200.0, currency: 'usd' },
|
||||
];
|
||||
|
||||
const POPULAR_CREDITS_AMOUNT = 6000;
|
||||
const STEP_SELECT = 'select';
|
||||
const STEP_CONFIRM = 'confirm';
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const selectedCredits = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const currentStep = ref(STEP_SELECT);
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return TOPUP_OPTIONS.find(o => o.credits === selectedCredits.value);
|
||||
});
|
||||
|
||||
const formattedAmount = computed(() => {
|
||||
if (!selectedOption.value) return '';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: selectedOption.value.currency.toUpperCase(),
|
||||
}).format(selectedOption.value.amount);
|
||||
});
|
||||
|
||||
const formattedCredits = computed(() => {
|
||||
if (!selectedOption.value) return '';
|
||||
return selectedOption.value.credits.toLocaleString();
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
return currentStep.value === STEP_SELECT
|
||||
? t('BILLING_SETTINGS.TOPUP.MODAL_TITLE')
|
||||
: t('BILLING_SETTINGS.TOPUP.CONFIRM.TITLE');
|
||||
});
|
||||
|
||||
const dialogDescription = computed(() => {
|
||||
return currentStep.value === STEP_SELECT
|
||||
? t('BILLING_SETTINGS.TOPUP.MODAL_DESCRIPTION')
|
||||
: '';
|
||||
});
|
||||
|
||||
const dialogWidth = computed(() => {
|
||||
return currentStep.value === STEP_SELECT ? 'xl' : 'md';
|
||||
});
|
||||
|
||||
const handlePackageSelect = credits => {
|
||||
selectedCredits.value = credits;
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
const popularOption = TOPUP_OPTIONS.find(
|
||||
o => o.credits === POPULAR_CREDITS_AMOUNT
|
||||
);
|
||||
selectedCredits.value = popularOption?.credits || TOPUP_OPTIONS[0]?.credits;
|
||||
currentStep.value = STEP_SELECT;
|
||||
isLoading.value = false;
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const goToConfirmStep = () => {
|
||||
if (!selectedOption.value) return;
|
||||
currentStep.value = STEP_CONFIRM;
|
||||
};
|
||||
|
||||
const goBackToSelectStep = () => {
|
||||
currentStep.value = STEP_SELECT;
|
||||
};
|
||||
|
||||
const handlePurchase = async () => {
|
||||
if (!selectedOption.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.createTopupCheckout(
|
||||
selectedOption.value.credits
|
||||
);
|
||||
|
||||
close();
|
||||
emit('success', response.data);
|
||||
useAlert(
|
||||
t('BILLING_SETTINGS.TOPUP.PURCHASE_SUCCESS', {
|
||||
credits: response.data.credits,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error.response?.data?.error || t('BILLING_SETTINGS.TOPUP.PURCHASE_ERROR');
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="dialogTitle"
|
||||
:description="dialogDescription"
|
||||
:width="dialogWidth"
|
||||
:show-confirm-button="false"
|
||||
:show-cancel-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- Step 1: Select Credits Package -->
|
||||
<template v-if="currentStep === 'select'">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<CreditPackageCard
|
||||
v-for="option in TOPUP_OPTIONS"
|
||||
:key="option.credits"
|
||||
name="credit-package"
|
||||
:credits="option.credits"
|
||||
:amount="option.amount"
|
||||
:currency="option.currency"
|
||||
:is-popular="option.credits === POPULAR_CREDITS_AMOUNT"
|
||||
:is-selected="selectedCredits === option.credits"
|
||||
@select="handlePackageSelect(option.credits)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mt-6 rounded-lg bg-n-solid-2 border border-n-weak">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
<span class="font-semibold text-n-slate-12">{{
|
||||
$t('BILLING_SETTINGS.TOPUP.NOTE_TITLE')
|
||||
}}</span>
|
||||
{{ $t('BILLING_SETTINGS.TOPUP.NOTE_DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: Confirm Purchase -->
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{
|
||||
$t('BILLING_SETTINGS.TOPUP.CONFIRM.DESCRIPTION', {
|
||||
credits: formattedCredits,
|
||||
amount: formattedAmount,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="p-2.5 rounded-lg bg-n-amber-2 border border-n-amber-6">
|
||||
<p class="text-sm text-n-amber-11">
|
||||
{{ $t('BILLING_SETTINGS.TOPUP.CONFIRM.INSTANT_DEDUCTION_NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<!-- Step 1 Footer -->
|
||||
<div
|
||||
v-if="currentStep === 'select'"
|
||||
class="flex items-center justify-between w-full gap-3"
|
||||
>
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="$t('BILLING_SETTINGS.TOPUP.CANCEL')"
|
||||
class="w-full"
|
||||
@click="close"
|
||||
/>
|
||||
<Button
|
||||
color="blue"
|
||||
:label="$t('BILLING_SETTINGS.TOPUP.PURCHASE')"
|
||||
class="w-full"
|
||||
:disabled="!selectedCredits"
|
||||
@click="goToConfirmStep"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 Footer -->
|
||||
<div v-else class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.GO_BACK')"
|
||||
class="w-full"
|
||||
:disabled="isLoading"
|
||||
@click="goBackToSelectStep"
|
||||
/>
|
||||
<Button
|
||||
color="blue"
|
||||
:label="$t('BILLING_SETTINGS.TOPUP.CONFIRM.CONFIRM_PURCHASE')"
|
||||
class="w-full"
|
||||
:is-loading="isLoading"
|
||||
@click="handlePurchase"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -110,6 +110,7 @@ export default {
|
||||
v-model="content"
|
||||
class="message-editor [&>div]:px-1"
|
||||
:class="{ editor_warning: v$.content.$error }"
|
||||
channel-type="Context::Default"
|
||||
enable-variables
|
||||
:enable-canned-responses="false"
|
||||
:placeholder="$t('CANNED_MGMT.ADD.FORM.CONTENT.PLACEHOLDER')"
|
||||
|
||||
@@ -114,6 +114,7 @@ export default {
|
||||
v-model="content"
|
||||
class="message-editor [&>div]:px-1"
|
||||
:class="{ editor_warning: v$.content.$error }"
|
||||
channel-type="Context::Default"
|
||||
enable-variables
|
||||
:enable-canned-responses="false"
|
||||
:placeholder="$t('CANNED_MGMT.EDIT.FORM.CONTENT.PLACEHOLDER')"
|
||||
|
||||
@@ -27,7 +27,6 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
|
||||
@@ -81,7 +80,6 @@ export default {
|
||||
selectedTabIndex: 0,
|
||||
selectedPortalSlug: '',
|
||||
showBusinessNameInput: false,
|
||||
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
|
||||
healthData: null,
|
||||
isLoadingHealth: false,
|
||||
healthError: null,
|
||||
@@ -626,7 +624,7 @@ export default {
|
||||
)
|
||||
"
|
||||
:max-length="255"
|
||||
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
|
||||
channel-type="Context::InboxSettings"
|
||||
/>
|
||||
|
||||
<label v-if="isAWebWidgetInbox" class="pb-4">
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
@@ -76,7 +75,6 @@ export default {
|
||||
checked: false,
|
||||
},
|
||||
],
|
||||
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -337,7 +335,7 @@ export default {
|
||||
)
|
||||
"
|
||||
:max-length="255"
|
||||
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
|
||||
channel-type="Context::InboxSettings"
|
||||
class="mb-4"
|
||||
/>
|
||||
<label>
|
||||
|
||||
@@ -5,7 +5,6 @@ import router from '../../../../index';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
|
||||
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
|
||||
export default {
|
||||
@@ -24,7 +23,6 @@ export default {
|
||||
channelWelcomeTagline: '',
|
||||
greetingEnabled: false,
|
||||
greetingMessage: '',
|
||||
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -147,7 +145,7 @@ export default {
|
||||
)
|
||||
"
|
||||
:max-length="255"
|
||||
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
|
||||
channel-type="Context::InboxSettings"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import { MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -12,7 +11,6 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updateSignature']);
|
||||
const customEditorMenuList = MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS;
|
||||
const signature = ref(props.messageSignature);
|
||||
watch(
|
||||
() => props.messageSignature ?? '',
|
||||
@@ -34,7 +32,7 @@ const updateSignature = () => {
|
||||
class="message-editor h-[10rem] !px-3"
|
||||
is-format-mode
|
||||
:placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')"
|
||||
:enabled-menu-options="customEditorMenuList"
|
||||
channel-type="Context::MessageSignature"
|
||||
:enable-suggestions="false"
|
||||
show-image-resize-toolbar
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,7 @@ const state = {
|
||||
isFetchingItem: false,
|
||||
isUpdating: false,
|
||||
isCheckoutInProcess: false,
|
||||
isFetchingLimits: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -141,11 +142,14 @@ export const actions = {
|
||||
},
|
||||
|
||||
limits: async ({ commit }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: true });
|
||||
try {
|
||||
const response = await EnterpriseAccountAPI.getLimits();
|
||||
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
|
||||
} catch (error) {
|
||||
// silent error
|
||||
} finally {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+36
-15
@@ -30,21 +30,15 @@ class DataImportJob < ApplicationJob
|
||||
def parse_csv_and_build_contacts
|
||||
contacts = []
|
||||
rejected_contacts = []
|
||||
# Ensuring that importing non utf-8 characters will not throw error
|
||||
data = @data_import.import_file.download
|
||||
utf8_data = data.force_encoding('UTF-8')
|
||||
|
||||
# Ensure that the data is valid UTF-8, preserving valid characters
|
||||
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
|
||||
|
||||
csv = CSV.parse(clean_data, headers: true)
|
||||
|
||||
csv.each do |row|
|
||||
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
|
||||
if current_contact.valid?
|
||||
contacts << current_contact
|
||||
else
|
||||
append_rejected_contact(row, current_contact, rejected_contacts)
|
||||
with_import_file do |file|
|
||||
csv_reader(file).each do |row|
|
||||
current_contact = @contact_manager.build_contact(row.to_h.with_indifferent_access)
|
||||
if current_contact.valid?
|
||||
contacts << current_contact
|
||||
else
|
||||
append_rejected_contact(row, current_contact, rejected_contacts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -75,7 +69,7 @@ class DataImportJob < ApplicationJob
|
||||
end
|
||||
|
||||
def generate_csv_data(rejected_contacts)
|
||||
headers = CSV.parse(@data_import.import_file.download, headers: true).headers
|
||||
headers = csv_headers
|
||||
headers << 'errors'
|
||||
return if rejected_contacts.blank?
|
||||
|
||||
@@ -99,4 +93,31 @@ class DataImportJob < ApplicationJob
|
||||
def send_import_failed_notification_to_admin
|
||||
AdministratorNotifications::AccountNotificationMailer.with(account: @data_import.account).contact_import_failed.deliver_later
|
||||
end
|
||||
|
||||
def csv_headers
|
||||
header_row = nil
|
||||
with_import_file do |file|
|
||||
header_row = csv_reader(file).first
|
||||
end
|
||||
header_row&.headers || []
|
||||
end
|
||||
|
||||
def csv_reader(file)
|
||||
file.rewind
|
||||
raw_data = file.read
|
||||
utf8_data = raw_data.force_encoding('UTF-8')
|
||||
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
|
||||
|
||||
CSV.new(StringIO.new(clean_data), headers: true)
|
||||
end
|
||||
|
||||
def with_import_file
|
||||
temp_dir = Rails.root.join('tmp/imports')
|
||||
FileUtils.mkdir_p(temp_dir)
|
||||
|
||||
@data_import.import_file.open(tmpdir: temp_dir) do |file|
|
||||
file.binmode
|
||||
yield file
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Handles attachment processing for ConversationReplyMailer flows.
|
||||
module ConversationReplyMailerAttachmentHelper
|
||||
private
|
||||
|
||||
def process_attachments_as_files_for_email_reply
|
||||
# Attachment processing for direct email replies (when replying to a single message)
|
||||
#
|
||||
# How attachments are handled:
|
||||
# 1. Total file size (<20MB): Added directly to the email as proper attachments
|
||||
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
|
||||
|
||||
@options[:attachments] = []
|
||||
@large_attachments = []
|
||||
current_total_size = 0
|
||||
|
||||
@message.attachments.each do |attachment|
|
||||
current_total_size = handle_attachment_inline(current_total_size, attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def read_blob_content(blob)
|
||||
buffer = +''
|
||||
blob.open do |file|
|
||||
while (chunk = file.read(64.kilobytes))
|
||||
buffer << chunk
|
||||
end
|
||||
end
|
||||
buffer
|
||||
end
|
||||
|
||||
def handle_attachment_inline(current_total_size, attachment)
|
||||
blob = attachment.file.blob
|
||||
return current_total_size if blob.blank?
|
||||
|
||||
file_size = blob.byte_size
|
||||
attachment_name = attachment.file.filename.to_s
|
||||
|
||||
if current_total_size + file_size <= 20.megabytes
|
||||
content = read_blob_content(blob)
|
||||
mail.attachments[attachment_name] = {
|
||||
mime_type: attachment.file.content_type || 'application/octet-stream',
|
||||
content: content
|
||||
}
|
||||
@options[:attachments] << { name: attachment_name }
|
||||
current_total_size + file_size
|
||||
else
|
||||
@large_attachments << attachment
|
||||
current_total_size
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
module ConversationReplyMailerHelper
|
||||
include ConversationReplyMailerAttachmentHelper
|
||||
|
||||
def prepare_mail(cc_bcc_enabled)
|
||||
@options = {
|
||||
to: to_emails,
|
||||
@@ -27,34 +29,6 @@ module ConversationReplyMailerHelper
|
||||
mail(@options)
|
||||
end
|
||||
|
||||
def process_attachments_as_files_for_email_reply
|
||||
# Attachment processing for direct email replies (when replying to a single message)
|
||||
#
|
||||
# How attachments are handled:
|
||||
# 1. Total file size (<20MB): Added directly to the email as proper attachments
|
||||
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
|
||||
|
||||
@options[:attachments] = []
|
||||
@large_attachments = []
|
||||
current_total_size = 0
|
||||
|
||||
@message.attachments.each do |attachment|
|
||||
raw_data = attachment.file.download
|
||||
attachment_name = attachment.file.filename.to_s
|
||||
file_size = raw_data.bytesize
|
||||
|
||||
# Attach files directly until we hit 20MB total
|
||||
# After reaching 20MB, send remaining files as links
|
||||
if current_total_size + file_size <= 20.megabytes
|
||||
mail.attachments[attachment_name] = raw_data
|
||||
@options[:attachments] << { name: attachment_name }
|
||||
current_total_size += file_size
|
||||
else
|
||||
@large_attachments << attachment
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def oauth_smtp_settings
|
||||
|
||||
@@ -40,7 +40,7 @@ class Attachment < ApplicationRecord
|
||||
validate :acceptable_file
|
||||
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
|
||||
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
|
||||
:contact => 8, :ig_reel => 9 }
|
||||
:contact => 8, :ig_reel => 9, :ig_post => 10, :ig_story => 11 }
|
||||
|
||||
def push_event_data
|
||||
return unless file_type
|
||||
|
||||
@@ -130,30 +130,39 @@ class Channel::Telegram < ApplicationRecord
|
||||
def convert_markdown_to_telegram_html(text)
|
||||
# ref: https://core.telegram.org/bots/api#html-style
|
||||
|
||||
# escape html tags in text. We are subbing \n to <br> since commonmark will strip exta '\n'
|
||||
text = CGI.escapeHTML(text.gsub("\n", '<br>'))
|
||||
# Escape HTML entities first to prevent HTML injection
|
||||
# This ensures only markdown syntax is converted, not raw HTML
|
||||
escaped_text = CGI.escapeHTML(text)
|
||||
|
||||
# convert markdown to html
|
||||
html = CommonMarker.render_html(text).strip
|
||||
# Parse markdown with extensions:
|
||||
# - strikethrough: support ~~text~~
|
||||
# - hardbreaks: preserve all newlines as <br>
|
||||
html = CommonMarker.render_html(escaped_text, [:HARDBREAKS], [:strikethrough]).strip
|
||||
|
||||
# remove all html tags except b, strong, i, em, u, ins, s, strike, del, a, code, pre, blockquote
|
||||
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html, tags: %w[b strong i em u ins s strike del a code pre blockquote],
|
||||
attributes: %w[href])
|
||||
# Convert paragraph breaks to double newlines to preserve them
|
||||
# CommonMarker creates <p> tags for paragraph breaks, but Telegram doesn't support <p>
|
||||
html_with_breaks = html.gsub(%r{</p>\s*<p>}, "\n\n")
|
||||
|
||||
# converted escaped br tags to \n
|
||||
stripped_html.gsub('<br>', "\n")
|
||||
# Remove opening and closing <p> tags
|
||||
html_with_breaks = html_with_breaks.gsub(%r{</?p>}, '')
|
||||
|
||||
# Sanitize to only allowed tags
|
||||
stripped_html = Rails::HTML5::SafeListSanitizer.new.sanitize(html_with_breaks, tags: %w[b strong i em u ins s strike del a code pre blockquote],
|
||||
attributes: %w[href])
|
||||
|
||||
# Convert <br /> tags to newlines for Telegram
|
||||
stripped_html.gsub(%r{<br\s*/?>}, "\n")
|
||||
end
|
||||
|
||||
def message_request(chat_id, text, reply_markup = nil, reply_to_message_id = nil, business_connection_id: nil)
|
||||
text_payload = convert_markdown_to_telegram_html(text)
|
||||
|
||||
# text is already converted to HTML by MessageContentPresenter
|
||||
business_body = {}
|
||||
business_body[:business_connection_id] = business_connection_id if business_connection_id
|
||||
|
||||
HTTParty.post("#{telegram_api_url}/sendMessage",
|
||||
body: {
|
||||
chat_id: chat_id,
|
||||
text: text_payload,
|
||||
text: text,
|
||||
reply_markup: reply_markup,
|
||||
parse_mode: 'HTML',
|
||||
reply_to_message_id: reply_to_message_id
|
||||
|
||||
@@ -53,7 +53,7 @@ class Integrations::App
|
||||
when 'slack'
|
||||
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
|
||||
when 'linear'
|
||||
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
|
||||
account.feature_enabled?('linear_integration') && GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
|
||||
when 'shopify'
|
||||
shopify_enabled?(account)
|
||||
when 'leadsquared'
|
||||
|
||||
@@ -30,4 +30,8 @@ class AccountPolicy < ApplicationPolicy
|
||||
def toggle_deletion?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def topup_checkout?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
class MessageContentPresenter < SimpleDelegator
|
||||
def outgoing_content
|
||||
return content unless should_append_survey_link?
|
||||
content_to_send = if should_append_survey_link?
|
||||
survey_link = survey_url(conversation.uuid)
|
||||
custom_message = inbox.csat_config&.dig('message')
|
||||
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
|
||||
else
|
||||
content
|
||||
end
|
||||
|
||||
survey_link = survey_url(conversation.uuid)
|
||||
custom_message = inbox.csat_config&.dig('message')
|
||||
|
||||
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
|
||||
Messages::MarkdownRendererService.new(
|
||||
content_to_send,
|
||||
conversation.inbox.channel_type,
|
||||
conversation.inbox.channel
|
||||
).render
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -59,11 +59,20 @@ class Instagram::MessageText < Instagram::BaseMessageText
|
||||
# We can safely create an unknown contact, making this integration work.
|
||||
return unknown_user(ig_scope_id) if error_code == 9010
|
||||
|
||||
# Handle error code 100: Object doesn't exist or missing permissions
|
||||
# This typically occurs when trying to fetch a user that doesn't exist or has privacy restrictions
|
||||
# We can safely create an unknown contact, similar to error 9010
|
||||
return unknown_user(ig_scope_id) if error_code == 100
|
||||
|
||||
Rails.logger.warn("[InstagramUserFetchError]: account_id #{@inbox.account_id} inbox_id #{@inbox.id} ig_scope_id #{ig_scope_id}")
|
||||
Rails.logger.warn("[InstagramUserFetchError]: #{error_message} #{error_code}")
|
||||
|
||||
exception = StandardError.new("#{error_message} (Code: #{error_code}, IG Scope ID: #{ig_scope_id})")
|
||||
ChatwootExceptionTracker.new(exception, account: @inbox.account).capture_exception
|
||||
|
||||
# Explicitly return empty hash for any unhandled error codes
|
||||
# This prevents the exception tracker result from being returned
|
||||
{}
|
||||
end
|
||||
|
||||
def base_uri
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
class Messages::MarkdownRendererService
|
||||
CHANNEL_RENDERERS = {
|
||||
'Channel::Email' => :render_html,
|
||||
'Channel::WebWidget' => :render_html,
|
||||
'Channel::Telegram' => :render_telegram_html,
|
||||
'Channel::Whatsapp' => :render_whatsapp,
|
||||
'Channel::FacebookPage' => :render_instagram,
|
||||
'Channel::Instagram' => :render_instagram,
|
||||
'Channel::Line' => :render_line,
|
||||
'Channel::TwitterProfile' => :render_plain_text,
|
||||
'Channel::Sms' => :render_plain_text,
|
||||
'Channel::TwilioSms' => :render_plain_text
|
||||
}.freeze
|
||||
|
||||
def initialize(content, channel_type, channel = nil)
|
||||
@content = content
|
||||
@channel_type = channel_type
|
||||
@channel = channel
|
||||
end
|
||||
|
||||
def render
|
||||
return @content if @content.blank?
|
||||
|
||||
renderer_method = CHANNEL_RENDERERS[effective_channel_type]
|
||||
renderer_method ? send(renderer_method) : @content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def effective_channel_type
|
||||
# For Twilio SMS channel, check if it's actually WhatsApp
|
||||
if @channel_type == 'Channel::TwilioSms' && @channel&.whatsapp?
|
||||
'Channel::Whatsapp'
|
||||
else
|
||||
@channel_type
|
||||
end
|
||||
end
|
||||
|
||||
def commonmarker_doc
|
||||
@commonmarker_doc ||= CommonMarker.render_doc(@content, [:DEFAULT, :STRIKETHROUGH_DOUBLE_TILDE])
|
||||
end
|
||||
|
||||
def render_html
|
||||
markdown_renderer = BaseMarkdownRenderer.new
|
||||
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
|
||||
markdown_renderer.render(doc)
|
||||
end
|
||||
|
||||
def render_telegram_html
|
||||
renderer = Messages::MarkdownRenderers::TelegramRenderer.new
|
||||
doc = CommonMarker.render_doc(@content, [:STRIKETHROUGH_DOUBLE_TILDE], [:strikethrough])
|
||||
renderer.render(doc).gsub(/\n+\z/, '')
|
||||
end
|
||||
|
||||
def render_whatsapp
|
||||
renderer = Messages::MarkdownRenderers::WhatsAppRenderer.new
|
||||
renderer.render(commonmarker_doc).gsub(/\n+\z/, '')
|
||||
end
|
||||
|
||||
def render_instagram
|
||||
renderer = Messages::MarkdownRenderers::InstagramRenderer.new
|
||||
renderer.render(commonmarker_doc).gsub(/\n+\z/, '')
|
||||
end
|
||||
|
||||
def render_line
|
||||
renderer = Messages::MarkdownRenderers::LineRenderer.new
|
||||
renderer.render(commonmarker_doc).gsub(/\n+\z/, '')
|
||||
end
|
||||
|
||||
def render_plain_text
|
||||
renderer = Messages::MarkdownRenderers::PlainTextRenderer.new
|
||||
renderer.render(commonmarker_doc).gsub(/\n+\z/, '')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
class Messages::MarkdownRenderers::BaseMarkdownRenderer < CommonMarker::Renderer
|
||||
def document(_node)
|
||||
out(:children)
|
||||
end
|
||||
|
||||
def paragraph(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def text(node)
|
||||
out(node.string_content)
|
||||
end
|
||||
|
||||
def softbreak(_node)
|
||||
out(' ')
|
||||
end
|
||||
|
||||
def linebreak(_node)
|
||||
out("\n")
|
||||
end
|
||||
|
||||
def strikethrough(_node)
|
||||
out('<del>')
|
||||
out(:children)
|
||||
out('</del>')
|
||||
end
|
||||
|
||||
def method_missing(method_name, node = nil, *args, **kwargs, &)
|
||||
return super unless node.is_a?(CommonMarker::Node)
|
||||
|
||||
out(:children)
|
||||
cr unless %i[text softbreak linebreak].include?(node.type)
|
||||
end
|
||||
|
||||
def respond_to_missing?(_method_name, _include_private = false)
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
class Messages::MarkdownRenderers::InstagramRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
|
||||
def initialize
|
||||
super
|
||||
@list_item_number = 0
|
||||
end
|
||||
|
||||
def strong(_node)
|
||||
out('*', :children, '*')
|
||||
end
|
||||
|
||||
def emph(_node)
|
||||
out('_', :children, '_')
|
||||
end
|
||||
|
||||
def code(node)
|
||||
out(node.string_content)
|
||||
end
|
||||
|
||||
def link(node)
|
||||
out(node.url)
|
||||
end
|
||||
|
||||
def list(node)
|
||||
@list_type = node.list_type
|
||||
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def list_item(_node)
|
||||
if @list_type == :ordered_list
|
||||
out("#{@list_item_number}. ", :children)
|
||||
@list_item_number += 1
|
||||
else
|
||||
out('- ', :children)
|
||||
end
|
||||
cr
|
||||
end
|
||||
|
||||
def blockquote(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def softbreak(_node)
|
||||
out("\n")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
class Messages::MarkdownRenderers::LineRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
|
||||
def strong(_node)
|
||||
out(' *', :children, '* ')
|
||||
end
|
||||
|
||||
def emph(_node)
|
||||
out(' _', :children, '_ ')
|
||||
end
|
||||
|
||||
def code(node)
|
||||
out(' `', node.string_content, '` ')
|
||||
end
|
||||
|
||||
def link(node)
|
||||
out(node.url)
|
||||
end
|
||||
|
||||
def list(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def list_item(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def code_block(node)
|
||||
out(' ```', "\n", node.string_content, '``` ', "\n")
|
||||
end
|
||||
|
||||
def blockquote(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
class Messages::MarkdownRenderers::PlainTextRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
|
||||
def initialize
|
||||
super
|
||||
@list_item_number = 0
|
||||
end
|
||||
|
||||
def link(node)
|
||||
out(:children)
|
||||
out(' ', node.url) if node.url.present?
|
||||
end
|
||||
|
||||
def strong(_node)
|
||||
out(:children)
|
||||
end
|
||||
|
||||
def emph(_node)
|
||||
out(:children)
|
||||
end
|
||||
|
||||
def code(node)
|
||||
out(node.string_content)
|
||||
end
|
||||
|
||||
def list(node)
|
||||
@list_type = node.list_type
|
||||
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def list_item(_node)
|
||||
if @list_type == :ordered_list
|
||||
out("#{@list_item_number}. ", :children)
|
||||
@list_item_number += 1
|
||||
else
|
||||
out('- ', :children)
|
||||
end
|
||||
cr
|
||||
end
|
||||
|
||||
def blockquote(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def code_block(node)
|
||||
out(node.string_content, "\n")
|
||||
end
|
||||
|
||||
def header(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def thematic_break(_node)
|
||||
out("\n")
|
||||
end
|
||||
|
||||
def softbreak(_node)
|
||||
out("\n")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,60 @@
|
||||
class Messages::MarkdownRenderers::TelegramRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
|
||||
def initialize
|
||||
super
|
||||
@list_item_number = 0
|
||||
end
|
||||
|
||||
def strong(_node)
|
||||
out('<strong>', :children, '</strong>')
|
||||
end
|
||||
|
||||
def emph(_node)
|
||||
out('<em>', :children, '</em>')
|
||||
end
|
||||
|
||||
def code(node)
|
||||
out('<code>', node.string_content, '</code>')
|
||||
end
|
||||
|
||||
def link(node)
|
||||
out('<a href="', node.url, '">', :children, '</a>')
|
||||
end
|
||||
|
||||
def strikethrough(_node)
|
||||
out('<del>', :children, '</del>')
|
||||
end
|
||||
|
||||
def blockquote(_node)
|
||||
out('<blockquote>', :children, '</blockquote>')
|
||||
end
|
||||
|
||||
def code_block(node)
|
||||
out('<pre>', node.string_content, '</pre>')
|
||||
end
|
||||
|
||||
def list(node)
|
||||
@list_type = node.list_type
|
||||
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def list_item(_node)
|
||||
if @list_type == :ordered_list
|
||||
out("#{@list_item_number}. ", :children)
|
||||
@list_item_number += 1
|
||||
else
|
||||
out('• ', :children)
|
||||
end
|
||||
cr
|
||||
end
|
||||
|
||||
def header(_node)
|
||||
out('<strong>', :children, '</strong>')
|
||||
cr
|
||||
end
|
||||
|
||||
def softbreak(_node)
|
||||
out("\n")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
|
||||
def strong(_node)
|
||||
out('*', :children, '*')
|
||||
end
|
||||
|
||||
def emph(_node)
|
||||
out('_', :children, '_')
|
||||
end
|
||||
|
||||
def code(node)
|
||||
out('`', node.string_content, '`')
|
||||
end
|
||||
|
||||
def link(node)
|
||||
out(node.url)
|
||||
end
|
||||
|
||||
def list(_node)
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def list_item(_node)
|
||||
out('- ', :children)
|
||||
cr
|
||||
end
|
||||
|
||||
def blockquote(_node)
|
||||
out('> ', :children)
|
||||
cr
|
||||
end
|
||||
|
||||
def softbreak(_node)
|
||||
out("\n")
|
||||
end
|
||||
end
|
||||
@@ -96,11 +96,16 @@ class Telegram::SendAttachmentsService
|
||||
# Telegram picks up the file name from original field name, so we need to save the file with the original name.
|
||||
# Hence not using Tempfile here.
|
||||
def save_attachment_to_tempfile(attachment)
|
||||
raw_data = attachment.file.download
|
||||
temp_dir = Rails.root.join('tmp/uploads')
|
||||
temp_dir = Rails.root.join('tmp/uploads', "telegram-#{attachment.message_id}")
|
||||
FileUtils.mkdir_p(temp_dir)
|
||||
temp_file_path = File.join(temp_dir, attachment.file.filename.to_s)
|
||||
File.write(temp_file_path, raw_data, mode: 'wb')
|
||||
|
||||
File.open(temp_file_path, 'wb') do |file|
|
||||
attachment.file.blob.open do |blob_file|
|
||||
IO.copy_stream(blob_file, file)
|
||||
end
|
||||
end
|
||||
|
||||
temp_file_path
|
||||
end
|
||||
|
||||
|
||||
@@ -3,6 +3,6 @@ json.payload do
|
||||
json.id inbox_member.user.id
|
||||
json.name inbox_member.user.available_name
|
||||
json.avatar_url inbox_member.user.avatar_url
|
||||
json.availability_status inbox_member.user.account_users.find_by(account_id: @current_account.id).availability_status
|
||||
json.availability_status inbox_member.user.account_users.find_by(account_id: @current_account.id)&.availability_status
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<% if @message.content_attributes.dig('email', 'html_content', 'reply').present? %>
|
||||
<%= @message.content_attributes.dig('email', 'html_content', 'reply').html_safe %>
|
||||
<% elsif @message.content %>
|
||||
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
|
||||
<%= @message.outgoing_content.html_safe %>
|
||||
<% end %>
|
||||
<% if @large_attachments.present? %>
|
||||
<p>Attachments:</p>
|
||||
|
||||
@@ -122,6 +122,13 @@ en:
|
||||
invalid_token: Invalid or expired MFA token
|
||||
invalid_credentials: Invalid credentials or verification code
|
||||
feature_unavailable: MFA feature is not available. Please configure encryption keys.
|
||||
topup:
|
||||
credits_required: Credits amount is required
|
||||
invalid_credits: Invalid credits amount
|
||||
invalid_option: Invalid topup option
|
||||
plan_not_eligible: Top-ups are only available for paid plans. Please upgrade your plan first.
|
||||
stripe_customer_not_configured: Stripe customer not configured
|
||||
no_payment_method: No payment methods found. Please add a payment method before making a purchase.
|
||||
profile:
|
||||
mfa:
|
||||
enabled: MFA enabled successfully
|
||||
@@ -197,6 +204,8 @@ en:
|
||||
messages:
|
||||
instagram_story_content: '%{story_sender} mentioned you in the story: '
|
||||
instagram_deleted_story_content: This story is no longer available.
|
||||
instagram_shared_story_content: 'Shared story'
|
||||
instagram_shared_post_content: 'Shared post'
|
||||
deleted: This message was deleted
|
||||
whatsapp:
|
||||
list_button_label: 'Choose an item'
|
||||
|
||||
@@ -438,6 +438,7 @@ Rails.application.routes.draw do
|
||||
post :subscription
|
||||
get :limits
|
||||
post :toggle_deletion
|
||||
post :topup_checkout
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -55,6 +55,22 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
end
|
||||
|
||||
def topup_checkout
|
||||
return render json: { error: I18n.t('errors.topup.credits_required') }, status: :unprocessable_entity if params[:credits].blank?
|
||||
|
||||
service = Enterprise::Billing::TopupCheckoutService.new(account: @account)
|
||||
result = service.create_checkout_session(credits: params[:credits].to_i)
|
||||
|
||||
@account.reload
|
||||
render json: result.merge(
|
||||
id: @account.id,
|
||||
limits: @account.limits,
|
||||
custom_attributes: @account.custom_attributes
|
||||
)
|
||||
rescue Enterprise::Billing::TopupCheckoutService::Error, Stripe::StripeError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_cloud_env
|
||||
|
||||
@@ -1,31 +1,83 @@
|
||||
module Captain::ChatHelper
|
||||
include Integrations::LlmInstrumentation
|
||||
include Captain::ToolExecutionHelper
|
||||
include Captain::ChatResponseHelper
|
||||
|
||||
def request_chat_completion
|
||||
log_chat_completion_request
|
||||
|
||||
chat = build_chat
|
||||
|
||||
add_messages_to_chat(chat)
|
||||
with_agent_session do
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
@client.chat(
|
||||
parameters: chat_parameters
|
||||
)
|
||||
end
|
||||
handle_response(response)
|
||||
response = chat.ask(conversation_messages.last[:content])
|
||||
build_response(response)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
|
||||
raise e
|
||||
end
|
||||
|
||||
def instrumentation_params
|
||||
private
|
||||
|
||||
def build_chat
|
||||
llm_chat = chat(model: @model, temperature: temperature)
|
||||
llm_chat.with_params(response_format: { type: 'json_object' })
|
||||
|
||||
llm_chat = setup_tools(llm_chat)
|
||||
setup_system_instructions(llm_chat)
|
||||
setup_event_handlers(llm_chat)
|
||||
|
||||
llm_chat
|
||||
end
|
||||
|
||||
def setup_tools(chat)
|
||||
@tools&.each do |tool|
|
||||
chat.with_tool(tool)
|
||||
end
|
||||
chat
|
||||
end
|
||||
|
||||
def setup_system_instructions(chat)
|
||||
system_messages = @messages.select { |m| m[:role] == 'system' || m[:role] == :system }
|
||||
combined_instructions = system_messages.pluck(:content).join("\n\n")
|
||||
chat.with_instructions(combined_instructions)
|
||||
end
|
||||
|
||||
def setup_event_handlers(chat)
|
||||
chat.on_new_message { start_llm_turn_span(instrumentation_params(chat)) }
|
||||
chat.on_end_message { |message| end_llm_turn_span(message) }
|
||||
chat.on_tool_call { |tool_call| handle_tool_call(tool_call) }
|
||||
chat.on_tool_result { |result| handle_tool_result(result) }
|
||||
|
||||
chat
|
||||
end
|
||||
|
||||
def handle_tool_call(tool_call)
|
||||
persist_thinking_message(tool_call)
|
||||
start_tool_span(tool_call)
|
||||
@pending_tool_calls ||= []
|
||||
@pending_tool_calls.push(tool_call)
|
||||
end
|
||||
|
||||
def handle_tool_result(result)
|
||||
end_tool_span(result)
|
||||
persist_tool_completion
|
||||
end
|
||||
|
||||
def add_messages_to_chat(chat)
|
||||
conversation_messages[0...-1].each do |msg|
|
||||
chat.add_message(role: msg[:role].to_sym, content: msg[:content])
|
||||
end
|
||||
end
|
||||
|
||||
def instrumentation_params(chat = nil)
|
||||
{
|
||||
span_name: "llm.captain.#{feature_name}",
|
||||
account_id: resolved_account_id,
|
||||
conversation_id: @conversation_id,
|
||||
feature_name: feature_name,
|
||||
model: @model,
|
||||
messages: @messages,
|
||||
messages: chat ? chat.messages.map { |m| { role: m.role.to_s, content: m.content.to_s } } : @messages,
|
||||
temperature: temperature,
|
||||
metadata: {
|
||||
assistant_id: @assistant&.id
|
||||
@@ -33,14 +85,8 @@ module Captain::ChatHelper
|
||||
}
|
||||
end
|
||||
|
||||
def chat_parameters
|
||||
{
|
||||
model: @model,
|
||||
messages: @messages,
|
||||
tools: @tool_registry&.registered_tools || [],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: temperature
|
||||
}
|
||||
def conversation_messages
|
||||
@messages.reject { |m| m[:role] == 'system' || m[:role] == :system }
|
||||
end
|
||||
|
||||
def temperature
|
||||
@@ -51,8 +97,6 @@ module Captain::ChatHelper
|
||||
@account&.id || @assistant&.account_id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Ensures all LLM calls and tool executions within an agentic loop
|
||||
# are grouped under a single trace/session in Langfuse.
|
||||
#
|
||||
@@ -78,7 +122,7 @@ module Captain::ChatHelper
|
||||
def log_chat_completion_request
|
||||
Rails.logger.info(
|
||||
"#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion
|
||||
for messages #{@messages} with #{@tool_registry&.registered_tools&.length || 0} tools
|
||||
for messages #{@messages} with #{@tools&.length || 0} tools
|
||||
"
|
||||
)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
module Captain::ChatResponseHelper
|
||||
private
|
||||
|
||||
def build_response(response)
|
||||
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
|
||||
|
||||
parsed = parse_json_response(response.content)
|
||||
|
||||
persist_message(parsed, 'assistant')
|
||||
parsed
|
||||
end
|
||||
|
||||
def parse_json_response(content)
|
||||
content = content.gsub('```json', '').gsub('```', '')
|
||||
content = content.strip
|
||||
JSON.parse(content)
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error parsing JSON response: #{e.message}"
|
||||
{ 'content' => content }
|
||||
end
|
||||
|
||||
def persist_thinking_message(tool_call)
|
||||
return if @copilot_thread.blank?
|
||||
|
||||
tool_name = tool_call.name.to_s
|
||||
|
||||
persist_message(
|
||||
{
|
||||
'content' => "Using #{tool_name}",
|
||||
'function_name' => tool_name
|
||||
},
|
||||
'assistant_thinking'
|
||||
)
|
||||
end
|
||||
|
||||
def persist_tool_completion
|
||||
return if @copilot_thread.blank?
|
||||
|
||||
tool_call = @pending_tool_calls&.pop
|
||||
return unless tool_call
|
||||
|
||||
tool_name = tool_call.name.to_s
|
||||
|
||||
persist_message(
|
||||
{
|
||||
'content' => "Completed #{tool_name}",
|
||||
'function_name' => tool_name
|
||||
},
|
||||
'assistant_thinking'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,83 +0,0 @@
|
||||
module Captain::ToolExecutionHelper
|
||||
private
|
||||
|
||||
def handle_response(response)
|
||||
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
|
||||
message = response.dig('choices', 0, 'message')
|
||||
|
||||
if message['tool_calls']
|
||||
process_tool_calls(message['tool_calls'])
|
||||
else
|
||||
message = JSON.parse(message['content'].strip)
|
||||
persist_message(message, 'assistant')
|
||||
message
|
||||
end
|
||||
end
|
||||
|
||||
def process_tool_calls(tool_calls)
|
||||
append_tool_calls(tool_calls)
|
||||
tool_calls.each { |tool_call| process_tool_call(tool_call) }
|
||||
request_chat_completion
|
||||
end
|
||||
|
||||
def process_tool_call(tool_call)
|
||||
arguments = JSON.parse(tool_call['function']['arguments'])
|
||||
function_name = tool_call['function']['name']
|
||||
tool_call_id = tool_call['id']
|
||||
|
||||
if @tool_registry.respond_to?(function_name)
|
||||
execute_tool(function_name, arguments, tool_call_id)
|
||||
else
|
||||
process_invalid_tool_call(function_name, tool_call_id)
|
||||
end
|
||||
end
|
||||
|
||||
def execute_tool(function_name, arguments, tool_call_id)
|
||||
persist_tool_status(function_name, 'captain.copilot.using_tool')
|
||||
result = perform_tool_call(function_name, arguments)
|
||||
persist_tool_status(function_name, 'captain.copilot.completed_tool_call')
|
||||
append_tool_response(result, tool_call_id)
|
||||
end
|
||||
|
||||
def perform_tool_call(function_name, arguments)
|
||||
instrument_tool_call(function_name, arguments) do
|
||||
@tool_registry.send(function_name, arguments)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Tool #{function_name} failed: #{e.message}"
|
||||
"Error executing #{function_name}: #{e.message}"
|
||||
end
|
||||
|
||||
def persist_tool_status(function_name, translation_key)
|
||||
persist_message(
|
||||
{
|
||||
content: I18n.t(translation_key, function_name: function_name),
|
||||
function_name: function_name
|
||||
},
|
||||
'assistant_thinking'
|
||||
)
|
||||
end
|
||||
|
||||
def append_tool_calls(tool_calls)
|
||||
@messages << {
|
||||
role: 'assistant',
|
||||
tool_calls: tool_calls
|
||||
}
|
||||
end
|
||||
|
||||
def process_invalid_tool_call(function_name, tool_call_id)
|
||||
persist_message(
|
||||
{ content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name },
|
||||
'assistant_thinking'
|
||||
)
|
||||
append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id)
|
||||
end
|
||||
|
||||
def append_tool_response(content, tool_call_id)
|
||||
@messages << {
|
||||
role: 'tool',
|
||||
tool_call_id: tool_call_id,
|
||||
content: content
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,4 @@
|
||||
require 'openai'
|
||||
|
||||
class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
class Captain::Copilot::ChatService < Llm::BaseAiService
|
||||
include Captain::ChatHelper
|
||||
|
||||
attr_reader :assistant, :account, :user, :copilot_thread, :previous_history, :messages
|
||||
@@ -14,9 +12,10 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
@copilot_thread = nil
|
||||
@previous_history = []
|
||||
@conversation_id = config[:conversation_id]
|
||||
|
||||
setup_user(config)
|
||||
setup_message_history(config)
|
||||
register_tools
|
||||
@tools = build_tools
|
||||
@messages = build_messages(config)
|
||||
end
|
||||
|
||||
@@ -60,16 +59,19 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
end
|
||||
end
|
||||
|
||||
def register_tools
|
||||
@tool_registry = Captain::ToolRegistryService.new(@assistant, user: @user)
|
||||
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::GetArticleService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::GetContactService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::GetConversationService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchArticlesService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchContactsService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchConversationsService)
|
||||
@tool_registry.register_tool(Captain::Tools::Copilot::SearchLinearIssuesService)
|
||||
def build_tools
|
||||
tools = []
|
||||
|
||||
tools << Captain::Tools::SearchDocumentationService.new(@assistant, user: @user)
|
||||
tools << Captain::Tools::Copilot::GetConversationService.new(@assistant, user: @user)
|
||||
tools << Captain::Tools::Copilot::SearchConversationsService.new(@assistant, user: @user)
|
||||
tools << Captain::Tools::Copilot::GetContactService.new(@assistant, user: @user)
|
||||
tools << Captain::Tools::Copilot::GetArticleService.new(@assistant, user: @user)
|
||||
tools << Captain::Tools::Copilot::SearchArticlesService.new(@assistant, user: @user)
|
||||
tools << Captain::Tools::Copilot::SearchContactsService.new(@assistant, user: @user)
|
||||
tools << Captain::Tools::Copilot::SearchLinearIssuesService.new(@assistant, user: @user)
|
||||
|
||||
tools.select(&:active?)
|
||||
end
|
||||
|
||||
def system_message
|
||||
@@ -77,12 +79,16 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
role: 'system',
|
||||
content: Captain::Llm::SystemPromptsService.copilot_response_generator(
|
||||
@assistant.config['product_name'],
|
||||
@tool_registry.tools_summary,
|
||||
tools_summary,
|
||||
@assistant.config
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
def tools_summary
|
||||
@tools.map { |tool| "- #{tool.class.name}: #{tool.class.description}" }.join("\n")
|
||||
end
|
||||
|
||||
def account_id_context
|
||||
{
|
||||
role: 'system',
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
require 'openai'
|
||||
|
||||
class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
||||
class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
include Captain::ChatHelper
|
||||
|
||||
def initialize(assistant: nil, conversation_id: nil)
|
||||
@@ -8,9 +6,10 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
||||
|
||||
@assistant = assistant
|
||||
@conversation_id = conversation_id
|
||||
|
||||
@messages = [system_message]
|
||||
@response = ''
|
||||
register_tools
|
||||
@tools = build_tools
|
||||
end
|
||||
|
||||
# additional_message: A single message (String) from the user that should be appended to the chat.
|
||||
@@ -28,9 +27,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
||||
|
||||
private
|
||||
|
||||
def register_tools
|
||||
@tool_registry = Captain::ToolRegistryService.new(@assistant, user: nil)
|
||||
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
|
||||
def build_tools
|
||||
[Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
|
||||
end
|
||||
|
||||
def system_message
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Captain::Llm::ContactAttributesService < Llm::BaseOpenAiService
|
||||
class Captain::Llm::ContactAttributesService < Llm::LegacyBaseOpenAiService
|
||||
def initialize(assistant, conversation)
|
||||
super()
|
||||
@assistant = assistant
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Captain::Llm::ContactNotesService < Llm::BaseOpenAiService
|
||||
class Captain::Llm::ContactNotesService < Llm::LegacyBaseOpenAiService
|
||||
def initialize(assistant, conversation)
|
||||
super()
|
||||
@assistant = assistant
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Captain::Llm::ConversationFaqService < Llm::BaseOpenAiService
|
||||
class Captain::Llm::ConversationFaqService < Llm::LegacyBaseOpenAiService
|
||||
DISTANCE_THRESHOLD = 0.3
|
||||
|
||||
def initialize(assistant, conversation)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
require 'openai'
|
||||
|
||||
class Captain::Llm::EmbeddingService < Llm::BaseOpenAiService
|
||||
class Captain::Llm::EmbeddingService < Llm::LegacyBaseOpenAiService
|
||||
class EmbeddingsError < StandardError; end
|
||||
|
||||
def self.embedding_model
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Captain::Llm::FaqGeneratorService < Llm::BaseOpenAiService
|
||||
class Captain::Llm::FaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
def initialize(content, language = 'english')
|
||||
super()
|
||||
@language = language
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Captain::Llm::PaginatedFaqGeneratorService < Llm::BaseOpenAiService
|
||||
class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
# Default pages per chunk - easily configurable
|
||||
DEFAULT_PAGES_PER_CHUNK = 10
|
||||
MAX_ITERATIONS = 20 # Safety limit to prevent infinite loops
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Captain::Llm::PdfProcessingService < Llm::BaseOpenAiService
|
||||
class Captain::Llm::PdfProcessingService < Llm::LegacyBaseOpenAiService
|
||||
def initialize(document)
|
||||
super()
|
||||
@document = document
|
||||
@@ -29,12 +29,16 @@ class Captain::Llm::PdfProcessingService < Llm::BaseOpenAiService
|
||||
end
|
||||
end
|
||||
|
||||
def with_tempfile(&)
|
||||
def with_tempfile
|
||||
Tempfile.create(['pdf_upload', '.pdf'], binmode: true) do |temp_file|
|
||||
temp_file.write(document.pdf_file.download)
|
||||
temp_file.close
|
||||
document.pdf_file.blob.open do |blob_file|
|
||||
IO.copy_stream(blob_file, temp_file)
|
||||
end
|
||||
|
||||
File.open(temp_file.path, 'rb', &)
|
||||
temp_file.flush
|
||||
temp_file.rewind
|
||||
|
||||
yield temp_file
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Captain::Onboarding::WebsiteAnalyzerService < Llm::BaseOpenAiService
|
||||
class Captain::Onboarding::WebsiteAnalyzerService < Llm::LegacyBaseOpenAiService
|
||||
MAX_CONTENT_LENGTH = 8000
|
||||
|
||||
def initialize(website_url)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
class Captain::Tools::BaseTool < RubyLLM::Tool
|
||||
attr_accessor :assistant
|
||||
|
||||
def initialize(assistant, user: nil)
|
||||
@assistant = assistant
|
||||
@user = user
|
||||
super()
|
||||
end
|
||||
|
||||
def active?
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_has_permission(permission)
|
||||
return false if @user.blank?
|
||||
|
||||
account_user = AccountUser.find_by(account_id: @assistant.account_id, user_id: @user.id)
|
||||
return false if account_user.blank?
|
||||
|
||||
return account_user.custom_role.permissions.include?(permission) if account_user.custom_role.present?
|
||||
|
||||
account_user.administrator? || account_user.agent?
|
||||
end
|
||||
end
|
||||
@@ -1,32 +1,11 @@
|
||||
class Captain::Tools::Copilot::GetArticleService < Captain::Tools::BaseService
|
||||
def name
|
||||
class Captain::Tools::Copilot::GetArticleService < Captain::Tools::BaseTool
|
||||
def self.name
|
||||
'get_article'
|
||||
end
|
||||
description 'Get details of an article including its content and metadata'
|
||||
param :article_id, type: :number, desc: 'The ID of the article to retrieve', required: true
|
||||
|
||||
def description
|
||||
'Get details of an article including its content and metadata'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
article_id: {
|
||||
type: 'number',
|
||||
description: 'The ID of the article to retrieve'
|
||||
}
|
||||
},
|
||||
required: %w[article_id]
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
article_id = arguments['article_id']
|
||||
|
||||
Rails.logger.info { "#{self.class.name}: Article ID: #{article_id}" }
|
||||
|
||||
return 'Missing required parameters' if article_id.blank?
|
||||
|
||||
def execute(article_id:)
|
||||
article = Article.find_by(id: article_id, account_id: @assistant.account_id)
|
||||
return 'Article not found' if article.nil?
|
||||
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
class Captain::Tools::Copilot::GetContactService < Captain::Tools::BaseService
|
||||
def name
|
||||
class Captain::Tools::Copilot::GetContactService < Captain::Tools::BaseTool
|
||||
def self.name
|
||||
'get_contact'
|
||||
end
|
||||
description 'Get details of a contact including their profile information'
|
||||
param :contact_id, type: :number, desc: 'The ID of the contact to retrieve', required: true
|
||||
|
||||
def description
|
||||
'Get details of a contact including their profile information'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
contact_id: {
|
||||
type: 'number',
|
||||
description: 'The ID of the contact to retrieve'
|
||||
}
|
||||
},
|
||||
required: %w[contact_id]
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
contact_id = arguments['contact_id']
|
||||
|
||||
Rails.logger.info "#{self.class.name}: Contact ID: #{contact_id}"
|
||||
|
||||
return 'Missing required parameters' if contact_id.blank?
|
||||
|
||||
def execute(contact_id:)
|
||||
contact = Contact.find_by(id: contact_id, account_id: @assistant.account_id)
|
||||
return 'Contact not found' if contact.nil?
|
||||
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseService
|
||||
def name
|
||||
class Captain::Tools::Copilot::GetConversationService < Captain::Tools::BaseTool
|
||||
def self.name
|
||||
'get_conversation'
|
||||
end
|
||||
description 'Get details of a conversation including messages and contact information'
|
||||
|
||||
def description
|
||||
'Get details of a conversation including messages and contact information'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
conversation_id: {
|
||||
type: 'number',
|
||||
description: 'The ID of the conversation to retrieve'
|
||||
}
|
||||
},
|
||||
required: %w[conversation_id]
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
conversation_id = arguments['conversation_id']
|
||||
|
||||
Rails.logger.info "#{self.class.name}: Conversation ID: #{conversation_id}"
|
||||
|
||||
return 'Missing required parameters' if conversation_id.blank?
|
||||
param :conversation_id, type: :integer, desc: 'ID of the conversation to retrieve', required: true
|
||||
|
||||
def execute(conversation_id:)
|
||||
conversation = Conversation.find_by(display_id: conversation_id, account_id: @assistant.account_id)
|
||||
return 'Conversation not found' if conversation.blank?
|
||||
|
||||
|
||||
@@ -1,36 +1,18 @@
|
||||
class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseService
|
||||
def name
|
||||
class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseTool
|
||||
def self.name
|
||||
'search_articles'
|
||||
end
|
||||
description 'Search articles based on parameters'
|
||||
param :query, desc: 'Search articles by title or content (partial match)', required: false
|
||||
param :category_id, type: :number, desc: 'Filter articles by category ID', required: false
|
||||
param :status, type: :string, desc: 'Filter articles by status - MUST BE ONE OF: draft, published, archived', required: false
|
||||
|
||||
def description
|
||||
'Search articles based on parameters'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: properties,
|
||||
required: ['query']
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
query = arguments['query']
|
||||
category_id = arguments['category_id']
|
||||
status = arguments['status']
|
||||
|
||||
Rails.logger.info "#{self.class.name}: Query: #{query}, Category ID: #{category_id}, Status: #{status}"
|
||||
|
||||
return 'Missing required parameters' if query.blank?
|
||||
|
||||
articles = fetch_articles(query, category_id, status)
|
||||
|
||||
def execute(query: nil, category_id: nil, status: nil)
|
||||
articles = fetch_articles(query: query, category_id: category_id, status: status)
|
||||
return 'No articles found' unless articles.exists?
|
||||
|
||||
total_count = articles.count
|
||||
articles = articles.limit(100)
|
||||
|
||||
<<~RESPONSE
|
||||
#{total_count > 100 ? "Found #{total_count} articles (showing first 100)" : "Total number of articles: #{total_count}"}
|
||||
#{articles.map(&:to_llm_text).join("\n---\n")}
|
||||
@@ -43,29 +25,11 @@ class Captain::Tools::Copilot::SearchArticlesService < Captain::Tools::BaseServi
|
||||
|
||||
private
|
||||
|
||||
def fetch_articles(query, category_id, status)
|
||||
def fetch_articles(query:, category_id:, status:)
|
||||
articles = Article.where(account_id: @assistant.account_id)
|
||||
articles = articles.where('title ILIKE :query OR content ILIKE :query', query: "%#{query}%") if query.present?
|
||||
articles = articles.where(category_id: category_id) if category_id.present?
|
||||
articles = articles.where(status: status) if status.present?
|
||||
articles
|
||||
end
|
||||
|
||||
def properties
|
||||
{
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search articles by title or content (partial match)'
|
||||
},
|
||||
category_id: {
|
||||
type: 'number',
|
||||
description: 'Filter articles by category ID'
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: %w[draft published archived],
|
||||
description: 'Filter articles by status'
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseService
|
||||
def name
|
||||
class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseTool
|
||||
def self.name
|
||||
'search_contacts'
|
||||
end
|
||||
|
||||
def description
|
||||
'Search contacts based on query parameters'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: properties,
|
||||
required: []
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
email = arguments['email']
|
||||
phone_number = arguments['phone_number']
|
||||
name = arguments['name']
|
||||
|
||||
Rails.logger.info "#{self.class.name} Email: #{email}, Phone Number: #{phone_number}, Name: #{name}"
|
||||
description 'Search contacts based on query parameters'
|
||||
param :email, type: :string, desc: 'Filter contacts by email'
|
||||
param :phone_number, type: :string, desc: 'Filter contacts by phone number'
|
||||
param :name, type: :string, desc: 'Filter contacts by name (partial match)'
|
||||
|
||||
def execute(email: nil, phone_number: nil, name: nil)
|
||||
contacts = Contact.where(account_id: @assistant.account_id)
|
||||
contacts = contacts.where(email: email) if email.present?
|
||||
contacts = contacts.where(phone_number: phone_number) if phone_number.present?
|
||||
@@ -39,23 +26,4 @@ class Captain::Tools::Copilot::SearchContactsService < Captain::Tools::BaseServi
|
||||
def active?
|
||||
user_has_permission('contact_manage')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def properties
|
||||
{
|
||||
email: {
|
||||
type: 'string',
|
||||
description: 'Filter contacts by email'
|
||||
},
|
||||
phone_number: {
|
||||
type: 'string',
|
||||
description: 'Filter contacts by phone number'
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Filter contacts by name (partial match)'
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::BaseService
|
||||
def name
|
||||
'search_conversations'
|
||||
class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::BaseTool
|
||||
def self.name
|
||||
'search_conversation'
|
||||
end
|
||||
description 'Search conversations based on parameters'
|
||||
|
||||
def description
|
||||
'Search conversations based on parameters'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: properties,
|
||||
required: []
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
status = arguments['status']
|
||||
contact_id = arguments['contact_id']
|
||||
priority = arguments['priority']
|
||||
labels = arguments['labels']
|
||||
param :status, type: :string, desc: 'Status of the conversation'
|
||||
param :contact_id, type: :number, desc: 'Contact id'
|
||||
param :priority, type: :string, desc: 'Priority of conversation'
|
||||
param :labels, type: :string, desc: 'Labels available'
|
||||
|
||||
def execute(status: nil, contact_id: nil, priority: nil, labels: nil)
|
||||
conversations = get_conversations(status, contact_id, priority, labels)
|
||||
|
||||
return 'No conversations found' unless conversations.exists?
|
||||
@@ -58,13 +47,4 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
|
||||
@assistant.account
|
||||
).perform
|
||||
end
|
||||
|
||||
def properties
|
||||
{
|
||||
contact_id: { type: 'number', description: 'Filter conversations by contact ID' },
|
||||
status: { type: 'string', enum: %w[open resolved pending snoozed], description: 'Filter conversations by status' },
|
||||
priority: { type: 'string', enum: %w[low medium high urgent], description: 'Filter conversations by priority' },
|
||||
labels: { type: 'array', items: { type: 'string' }, description: 'Filter conversations by labels' }
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,34 +1,14 @@
|
||||
class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseService
|
||||
def name
|
||||
class Captain::Tools::Copilot::SearchLinearIssuesService < Captain::Tools::BaseTool
|
||||
def self.name
|
||||
'search_linear_issues'
|
||||
end
|
||||
|
||||
def description
|
||||
'Search Linear issues based on a search term'
|
||||
end
|
||||
description 'Search Linear issues based on a search term'
|
||||
param :term, type: :string, desc: 'The search term to find Linear issues', required: true
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
term: {
|
||||
type: 'string',
|
||||
description: 'The search term to find Linear issues'
|
||||
}
|
||||
},
|
||||
required: %w[term]
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
def execute(term:)
|
||||
return 'Linear integration is not enabled' unless active?
|
||||
|
||||
term = arguments['term']
|
||||
|
||||
Rails.logger.info "#{self.class.name}: Service called with the search term #{term}"
|
||||
|
||||
return 'Missing required parameters' if term.blank?
|
||||
|
||||
linear_service = Integrations::Linear::ProcessorService.new(account: @assistant.account)
|
||||
result = linear_service.search_issue(term)
|
||||
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseService
|
||||
def name
|
||||
class Captain::Tools::SearchDocumentationService < Captain::Tools::BaseTool
|
||||
def self.name
|
||||
'search_documentation'
|
||||
end
|
||||
description 'Search and retrieve documentation from knowledge base'
|
||||
|
||||
def description
|
||||
'Search and retrieve documentation from knowledge base'
|
||||
end
|
||||
param :query, desc: 'Search Query', required: true
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
search_query: {
|
||||
type: 'string',
|
||||
description: 'The search query to look up in the documentation.'
|
||||
}
|
||||
},
|
||||
required: ['search_query']
|
||||
}
|
||||
end
|
||||
|
||||
def execute(arguments)
|
||||
query = arguments['search_query']
|
||||
def execute(query:)
|
||||
Rails.logger.info { "#{self.class.name}: #{query}" }
|
||||
|
||||
responses = assistant.responses.approved.search(query)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Enterprise::Billing::HandleStripeEventService
|
||||
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
||||
CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.freeze
|
||||
|
||||
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
|
||||
# Each higher tier includes all features from the lower tiers
|
||||
@@ -46,23 +47,43 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
# skipping self hosted plan events
|
||||
return if plan.blank? || account.blank?
|
||||
|
||||
previous_usage = capture_previous_usage
|
||||
update_account_attributes(subscription, plan)
|
||||
update_plan_features
|
||||
reset_captain_usage
|
||||
|
||||
if billing_period_renewed?
|
||||
ActiveRecord::Base.transaction do
|
||||
handle_subscription_credits(plan, previous_usage)
|
||||
account.reset_response_usage
|
||||
end
|
||||
elsif plan_changed?
|
||||
handle_plan_change_credits(plan, previous_usage)
|
||||
end
|
||||
end
|
||||
|
||||
def capture_previous_usage
|
||||
{ responses: account.custom_attributes['captain_responses_usage'].to_i, monthly: current_plan_credits[:responses] }
|
||||
end
|
||||
|
||||
def current_plan_credits
|
||||
plan_name = account.custom_attributes['plan_name']
|
||||
return { responses: 0, documents: 0 } if plan_name.blank?
|
||||
|
||||
get_plan_credits(plan_name)
|
||||
end
|
||||
|
||||
def update_account_attributes(subscription, plan)
|
||||
# https://stripe.com/docs/api/subscriptions/object
|
||||
account.update(
|
||||
custom_attributes: {
|
||||
stripe_customer_id: subscription.customer,
|
||||
stripe_price_id: subscription['plan']['id'],
|
||||
stripe_product_id: subscription['plan']['product'],
|
||||
plan_name: plan['name'],
|
||||
subscribed_quantity: subscription['quantity'],
|
||||
subscription_status: subscription['status'],
|
||||
subscription_ends_on: Time.zone.at(subscription['current_period_end'])
|
||||
}
|
||||
custom_attributes: account.custom_attributes.merge(
|
||||
'stripe_customer_id' => subscription.customer,
|
||||
'stripe_price_id' => subscription['plan']['id'],
|
||||
'stripe_product_id' => subscription['plan']['product'],
|
||||
'plan_name' => plan['name'],
|
||||
'subscribed_quantity' => subscription['quantity'],
|
||||
'subscription_status' => subscription['status'],
|
||||
'subscription_ends_on' => Time.zone.at(subscription['current_period_end'])
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@@ -101,28 +122,47 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
enable_plan_specific_features
|
||||
end
|
||||
|
||||
def reset_captain_usage
|
||||
account.reset_response_usage
|
||||
def handle_subscription_credits(plan, previous_usage)
|
||||
current_limits = account.limits || {}
|
||||
|
||||
current_credits = current_limits['captain_responses'].to_i
|
||||
new_plan_credits = get_plan_credits(plan['name'])[:responses]
|
||||
|
||||
consumed_topup_credits = [previous_usage[:responses] - previous_usage[:monthly], 0].max
|
||||
updated_credits = current_credits - consumed_topup_credits - previous_usage[:monthly] + new_plan_credits
|
||||
|
||||
Rails.logger.info("Updating subscription credits for account #{account.id}: #{current_credits} -> #{updated_credits}")
|
||||
account.update!(limits: current_limits.merge('captain_responses' => updated_credits))
|
||||
end
|
||||
|
||||
def handle_plan_change_credits(new_plan, previous_usage)
|
||||
current_limits = account.limits || {}
|
||||
current_credits = current_limits['captain_responses'].to_i
|
||||
|
||||
previous_plan_credits = previous_usage[:monthly]
|
||||
new_plan_credits = get_plan_credits(new_plan['name'])[:responses]
|
||||
|
||||
updated_credits = current_credits - previous_plan_credits + new_plan_credits
|
||||
|
||||
account.update!(limits: current_limits.merge('captain_responses' => updated_credits))
|
||||
end
|
||||
|
||||
def get_plan_credits(plan_name)
|
||||
config = InstallationConfig.find_by(name: CAPTAIN_CLOUD_PLAN_LIMITS).value
|
||||
config = JSON.parse(config) if config.is_a?(String)
|
||||
config[plan_name.downcase]&.symbolize_keys
|
||||
end
|
||||
|
||||
def enable_plan_specific_features
|
||||
plan_name = account.custom_attributes['plan_name']
|
||||
return if plan_name.blank?
|
||||
|
||||
# Enable features based on plan hierarchy
|
||||
case plan_name
|
||||
when 'Startups'
|
||||
# Startups plan gets the basic features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
when 'Startups' then account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
when 'Business'
|
||||
# Business plan gets Startups features + Business features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES)
|
||||
when 'Enterprise'
|
||||
# Enterprise plan gets all features
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||
account.enable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||
account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES, *ENTERPRISE_PLAN_FEATURES)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -130,6 +170,25 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
@subscription ||= @event.data.object
|
||||
end
|
||||
|
||||
def previous_attributes
|
||||
@previous_attributes ||= JSON.parse((@event.data.previous_attributes || {}).to_json)
|
||||
end
|
||||
|
||||
def plan_changed?
|
||||
return false if previous_attributes['plan'].blank?
|
||||
|
||||
previous_plan_id = previous_attributes.dig('plan', 'id')
|
||||
current_plan_id = subscription['plan']['id']
|
||||
|
||||
previous_plan_id != current_plan_id
|
||||
end
|
||||
|
||||
def billing_period_renewed?
|
||||
return false if previous_attributes['current_period_start'].blank?
|
||||
|
||||
previous_attributes['current_period_start'] != subscription['current_period_start']
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first
|
||||
end
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
class Enterprise::Billing::TopupCheckoutService
|
||||
include BillingHelper
|
||||
|
||||
class Error < StandardError; end
|
||||
|
||||
TOPUP_OPTIONS = [
|
||||
{ credits: 1000, amount: 20.0, currency: 'usd' },
|
||||
{ credits: 2500, amount: 50.0, currency: 'usd' },
|
||||
{ credits: 6000, amount: 100.0, currency: 'usd' },
|
||||
{ credits: 12_000, amount: 200.0, currency: 'usd' }
|
||||
].freeze
|
||||
|
||||
pattr_initialize [:account!]
|
||||
|
||||
def create_checkout_session(credits:)
|
||||
topup_option = validate_and_find_topup_option(credits)
|
||||
charge_customer(topup_option, credits)
|
||||
fulfill_credits(credits, topup_option)
|
||||
|
||||
{
|
||||
credits: credits,
|
||||
amount: topup_option[:amount],
|
||||
currency: topup_option[:currency]
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_and_find_topup_option(credits)
|
||||
raise Error, I18n.t('errors.topup.invalid_credits') unless credits.to_i.positive?
|
||||
raise Error, I18n.t('errors.topup.plan_not_eligible') if default_plan?(account)
|
||||
raise Error, I18n.t('errors.topup.stripe_customer_not_configured') if stripe_customer_id.blank?
|
||||
|
||||
topup_option = find_topup_option(credits)
|
||||
raise Error, I18n.t('errors.topup.invalid_option') unless topup_option
|
||||
|
||||
# Validate payment method exists
|
||||
validate_payment_method!
|
||||
|
||||
topup_option
|
||||
end
|
||||
|
||||
def validate_payment_method!
|
||||
customer = Stripe::Customer.retrieve(stripe_customer_id)
|
||||
|
||||
return if customer.invoice_settings.default_payment_method.present? || customer.default_source.present?
|
||||
|
||||
# Auto-set first payment method as default if available
|
||||
payment_methods = Stripe::PaymentMethod.list(customer: stripe_customer_id, limit: 1)
|
||||
raise Error, I18n.t('errors.topup.no_payment_method') if payment_methods.data.empty?
|
||||
|
||||
Stripe::Customer.update(stripe_customer_id, invoice_settings: { default_payment_method: payment_methods.data.first.id })
|
||||
end
|
||||
|
||||
def charge_customer(topup_option, credits)
|
||||
amount_cents = (topup_option[:amount] * 100).to_i
|
||||
currency = topup_option[:currency]
|
||||
description = "AI Credits Topup: #{credits} credits"
|
||||
|
||||
invoice = Stripe::Invoice.create(
|
||||
customer: stripe_customer_id,
|
||||
currency: currency,
|
||||
collection_method: 'charge_automatically',
|
||||
auto_advance: false,
|
||||
description: description
|
||||
)
|
||||
|
||||
Stripe::InvoiceItem.create(
|
||||
customer: stripe_customer_id,
|
||||
amount: amount_cents,
|
||||
currency: currency,
|
||||
invoice: invoice.id,
|
||||
description: description
|
||||
)
|
||||
|
||||
Stripe::Invoice.finalize_invoice(invoice.id, { auto_advance: false })
|
||||
Stripe::Invoice.pay(invoice.id)
|
||||
end
|
||||
|
||||
def fulfill_credits(credits, topup_option)
|
||||
Enterprise::Billing::TopupFulfillmentService.new(account: account).fulfill(
|
||||
credits: credits,
|
||||
amount_cents: (topup_option[:amount] * 100).to_i,
|
||||
currency: topup_option[:currency]
|
||||
)
|
||||
end
|
||||
|
||||
def stripe_customer_id
|
||||
account.custom_attributes['stripe_customer_id']
|
||||
end
|
||||
|
||||
def find_topup_option(credits)
|
||||
TOPUP_OPTIONS.find { |opt| opt[:credits] == credits.to_i }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
class Enterprise::Billing::TopupFulfillmentService
|
||||
pattr_initialize [:account!]
|
||||
|
||||
def fulfill(credits:, amount_cents:, currency:)
|
||||
account.with_lock do
|
||||
create_stripe_credit_grant(credits, amount_cents, currency)
|
||||
update_account_credits(credits)
|
||||
end
|
||||
|
||||
Rails.logger.info("Topup fulfilled for account #{account.id}: #{credits} credits, #{amount_cents} cents")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_stripe_credit_grant(credits, amount_cents, currency)
|
||||
Stripe::Billing::CreditGrant.create(
|
||||
customer: stripe_customer_id,
|
||||
name: "Topup: #{credits} credits",
|
||||
amount: {
|
||||
type: 'monetary',
|
||||
monetary: { currency: currency, value: amount_cents }
|
||||
},
|
||||
applicability_config: {
|
||||
scope: { price_type: 'metered' }
|
||||
},
|
||||
category: 'paid',
|
||||
expires_at: 6.months.from_now.to_i,
|
||||
metadata: {
|
||||
account_id: account.id.to_s,
|
||||
source: 'topup',
|
||||
credits: credits.to_s
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def update_account_credits(credits)
|
||||
current_limits = account.limits || {}
|
||||
current_total = current_limits['captain_responses'].to_i
|
||||
new_total = current_total + credits
|
||||
|
||||
account.update!(
|
||||
limits: current_limits.merge(
|
||||
'captain_responses' => new_total
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def stripe_customer_id
|
||||
account.custom_attributes['stripe_customer_id']
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Internal::AccountAnalysis::ContentEvaluatorService < Llm::BaseOpenAiService
|
||||
class Internal::AccountAnalysis::ContentEvaluatorService < Llm::LegacyBaseOpenAiService
|
||||
def initialize
|
||||
super()
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Base service for LLM operations using RubyLLM.
|
||||
# New features should inherit from this class.
|
||||
class Llm::BaseAiService
|
||||
DEFAULT_MODEL = Llm::Config::DEFAULT_MODEL
|
||||
DEFAULT_TEMPERATURE = 1.0
|
||||
|
||||
attr_reader :model, :temperature
|
||||
|
||||
def initialize
|
||||
Llm::Config.initialize!
|
||||
setup_model
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
def setup_model
|
||||
config_value = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
|
||||
@model = (config_value.presence || DEFAULT_MODEL)
|
||||
end
|
||||
|
||||
def setup_temperature
|
||||
@temperature = DEFAULT_TEMPERATURE
|
||||
end
|
||||
end
|
||||
+8
-2
@@ -1,5 +1,11 @@
|
||||
class Llm::BaseOpenAiService
|
||||
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
|
||||
# frozen_string_literal: true
|
||||
|
||||
# DEPRECATED: This class uses the legacy OpenAI Ruby gem directly.
|
||||
# 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'
|
||||
|
||||
attr_reader :client, :model
|
||||
|
||||
def initialize
|
||||
@@ -1,4 +1,4 @@
|
||||
class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
|
||||
class Messages::AudioTranscriptionService < Llm::LegacyBaseOpenAiService
|
||||
attr_reader :attachment, :message, :account
|
||||
|
||||
def initialize(attachment)
|
||||
@@ -27,10 +27,16 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
|
||||
end
|
||||
|
||||
def fetch_audio_file
|
||||
temp_dir = Rails.root.join('tmp/uploads')
|
||||
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
|
||||
FileUtils.mkdir_p(temp_dir)
|
||||
temp_file_path = File.join(temp_dir, attachment.file.filename.to_s)
|
||||
File.write(temp_file_path, attachment.file.download, mode: 'wb')
|
||||
temp_file_path = File.join(temp_dir, "#{attachment.file.blob.key}-#{attachment.file.filename}")
|
||||
|
||||
File.open(temp_file_path, 'wb') do |file|
|
||||
attachment.file.blob.open do |blob_file|
|
||||
IO.copy_stream(blob_file, file)
|
||||
end
|
||||
end
|
||||
|
||||
temp_file_path
|
||||
end
|
||||
|
||||
@@ -40,18 +46,24 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService
|
||||
|
||||
temp_file_path = fetch_audio_file
|
||||
|
||||
response = @client.audio.transcribe(
|
||||
parameters: {
|
||||
model: 'whisper-1',
|
||||
file: File.open(temp_file_path),
|
||||
temperature: 0.4
|
||||
}
|
||||
)
|
||||
response_text = nil
|
||||
|
||||
File.open(temp_file_path, 'rb') do |file|
|
||||
response = @client.audio.transcribe(
|
||||
parameters: {
|
||||
model: 'whisper-1',
|
||||
file: file,
|
||||
temperature: 0.4
|
||||
}
|
||||
)
|
||||
|
||||
response_text = response['text']
|
||||
end
|
||||
|
||||
FileUtils.rm_f(temp_file_path)
|
||||
|
||||
update_transcription(response['text'])
|
||||
response['text']
|
||||
update_transcription(response_text)
|
||||
response_text
|
||||
end
|
||||
|
||||
def update_transcription(transcribed_text)
|
||||
|
||||
@@ -5,7 +5,7 @@ class ChatwootMarkdownRenderer
|
||||
|
||||
def render_message
|
||||
markdown_renderer = BaseMarkdownRenderer.new
|
||||
doc = CommonMarker.render_doc(@content, :DEFAULT)
|
||||
doc = CommonMarker.render_doc(@content, :DEFAULT, [:strikethrough])
|
||||
html = markdown_renderer.render(doc)
|
||||
render_as_html_safe(html)
|
||||
end
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class Integrations::OpenaiBaseService
|
||||
class Integrations::LlmBaseService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
# gpt-4o-mini supports 128,000 tokens
|
||||
@@ -6,8 +6,7 @@ class Integrations::OpenaiBaseService
|
||||
# sticking with 120000 to be safe
|
||||
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
|
||||
TOKEN_LIMIT = 400_000
|
||||
GPT_MODEL = ENV.fetch('OPENAI_GPT_MODEL', 'gpt-4o-mini').freeze
|
||||
|
||||
GPT_MODEL = Llm::Config::DEFAULT_MODEL
|
||||
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze
|
||||
CACHEABLE_EVENTS = %w[].freeze
|
||||
|
||||
@@ -82,10 +81,10 @@ class Integrations::OpenaiBaseService
|
||||
self.class::CACHEABLE_EVENTS.include?(event_name)
|
||||
end
|
||||
|
||||
def api_url
|
||||
def api_base
|
||||
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
|
||||
endpoint = endpoint.chomp('/')
|
||||
"#{endpoint}/v1/chat/completions"
|
||||
"#{endpoint}/v1"
|
||||
end
|
||||
|
||||
def make_api_call(body)
|
||||
@@ -93,10 +92,65 @@ class Integrations::OpenaiBaseService
|
||||
instrumentation_params = build_instrumentation_params(parsed_body)
|
||||
|
||||
instrument_llm_call(instrumentation_params) do
|
||||
execute_api_request(body, parsed_body['messages'])
|
||||
execute_ruby_llm_request(parsed_body)
|
||||
end
|
||||
end
|
||||
|
||||
def execute_ruby_llm_request(parsed_body)
|
||||
messages = parsed_body['messages']
|
||||
model = parsed_body['model']
|
||||
|
||||
Llm::Config.with_api_key(hook.settings['api_key'], api_base: api_base) do |context|
|
||||
chat = context.chat(model: model)
|
||||
setup_chat_with_messages(chat, messages)
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: hook.account).capture_exception
|
||||
build_error_response_from_exception(e, messages)
|
||||
end
|
||||
|
||||
def setup_chat_with_messages(chat, messages)
|
||||
apply_system_instructions(chat, messages)
|
||||
response = send_conversation_messages(chat, messages)
|
||||
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if response.nil?
|
||||
|
||||
build_ruby_llm_response(response, messages)
|
||||
end
|
||||
|
||||
def apply_system_instructions(chat, messages)
|
||||
system_msg = messages.find { |m| m['role'] == 'system' }
|
||||
chat.with_instructions(system_msg['content']) if system_msg
|
||||
end
|
||||
|
||||
def send_conversation_messages(chat, messages)
|
||||
conversation_messages = messages.reject { |m| m['role'] == 'system' }
|
||||
|
||||
return nil if conversation_messages.empty?
|
||||
|
||||
return chat.ask(conversation_messages.first['content']) if conversation_messages.length == 1
|
||||
|
||||
add_conversation_history(chat, conversation_messages[0...-1])
|
||||
chat.ask(conversation_messages.last['content'])
|
||||
end
|
||||
|
||||
def add_conversation_history(chat, messages)
|
||||
messages.each do |msg|
|
||||
chat.add_message(role: msg['role'].to_sym, content: msg['content'])
|
||||
end
|
||||
end
|
||||
|
||||
def build_ruby_llm_response(response, messages)
|
||||
{
|
||||
message: response.content,
|
||||
usage: {
|
||||
'prompt_tokens' => response.input_tokens,
|
||||
'completion_tokens' => response.output_tokens,
|
||||
'total_tokens' => (response.input_tokens || 0) + (response.output_tokens || 0)
|
||||
},
|
||||
request_messages: messages
|
||||
}
|
||||
end
|
||||
|
||||
def build_instrumentation_params(parsed_body)
|
||||
{
|
||||
span_name: "llm.#{event_name}",
|
||||
@@ -109,37 +163,7 @@ class Integrations::OpenaiBaseService
|
||||
}
|
||||
end
|
||||
|
||||
def execute_api_request(body, messages)
|
||||
Rails.logger.info("OpenAI API request: #{body}")
|
||||
response = HTTParty.post(api_url, headers: api_headers, body: body)
|
||||
Rails.logger.info("OpenAI API response: #{response.body}")
|
||||
|
||||
parse_api_response(response, messages)
|
||||
end
|
||||
|
||||
def api_headers
|
||||
{
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => "Bearer #{hook.settings['api_key']}"
|
||||
}
|
||||
end
|
||||
|
||||
def parse_api_response(response, messages)
|
||||
return build_error_response(response, messages) unless response.success?
|
||||
|
||||
parsed_response = JSON.parse(response.body)
|
||||
build_success_response(parsed_response, messages)
|
||||
end
|
||||
|
||||
def build_error_response(response, messages)
|
||||
{ error: response.parsed_response, error_code: response.code, request_messages: messages }
|
||||
end
|
||||
|
||||
def build_success_response(parsed_response, messages)
|
||||
choices = parsed_response['choices']
|
||||
usage = parsed_response['usage']
|
||||
message_content = choices.present? ? choices.first['message']['content'] : nil
|
||||
|
||||
{ message: message_content, usage: usage, request_messages: messages }
|
||||
def build_error_response_from_exception(error, messages)
|
||||
{ error: error.message, request_messages: messages }
|
||||
end
|
||||
end
|
||||
@@ -1,45 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'opentelemetry_config'
|
||||
require_relative 'llm_instrumentation_constants'
|
||||
|
||||
module Integrations::LlmInstrumentation
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
include Integrations::LlmInstrumentationHelpers
|
||||
include Integrations::LlmInstrumentationSpans
|
||||
|
||||
def tracer
|
||||
@tracer ||= OpentelemetryConfig.tracer
|
||||
end
|
||||
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?
|
||||
|
||||
result = nil
|
||||
executed = false
|
||||
tracer.in_span(params[:span_name]) do |span|
|
||||
setup_span_attributes(span, params)
|
||||
result = yield
|
||||
executed = true
|
||||
record_completion(span, result)
|
||||
result
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: params[:account]).capture_exception
|
||||
yield
|
||||
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
|
||||
executed ? result : yield
|
||||
end
|
||||
|
||||
def instrument_agent_session(params)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
result = nil
|
||||
executed = false
|
||||
tracer.in_span(params[:span_name]) do |span|
|
||||
set_request_attributes(span, params)
|
||||
set_metadata_attributes(span, params)
|
||||
|
||||
# By default, the input and output of a trace are set from the root observation
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
|
||||
result = yield
|
||||
executed = true
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
|
||||
|
||||
result
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: params[:account]).capture_exception
|
||||
yield
|
||||
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
|
||||
executed ? result : yield
|
||||
end
|
||||
|
||||
def instrument_tool_call(tool_name, arguments)
|
||||
@@ -55,8 +66,27 @@ module Integrations::LlmInstrumentation
|
||||
end
|
||||
end
|
||||
|
||||
def determine_provider(model_name)
|
||||
return 'openai' if model_name.blank?
|
||||
|
||||
model = model_name.to_s.downcase
|
||||
|
||||
PROVIDER_PREFIXES.each do |provider, prefixes|
|
||||
return provider if prefixes.any? { |prefix| model.start_with?(prefix) }
|
||||
end
|
||||
|
||||
'openai'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_account(params)
|
||||
return params[:account] if params[:account].is_a?(Account)
|
||||
return Account.find_by(id: params[:account_id]) if params[:account_id].present?
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def setup_span_attributes(span, params)
|
||||
set_request_attributes(span, params)
|
||||
set_prompt_messages(span, params[:messages])
|
||||
@@ -64,11 +94,17 @@ module Integrations::LlmInstrumentation
|
||||
end
|
||||
|
||||
def record_completion(span, result)
|
||||
set_completion_attributes(span, result) if result.is_a?(Hash)
|
||||
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)
|
||||
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
|
||||
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
|
||||
@@ -82,50 +118,4 @@ module Integrations::LlmInstrumentation
|
||||
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def set_metadata_attributes(span, params)
|
||||
session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
|
||||
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
|
||||
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present?
|
||||
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
|
||||
|
||||
return unless params[:metadata].is_a?(Hash)
|
||||
|
||||
params[:metadata].each do |key, value|
|
||||
span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s)
|
||||
end
|
||||
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?
|
||||
|
||||
error_code = result[:error_code] || result['error_code']
|
||||
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
|
||||
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, error_code) if error_code
|
||||
span.status = OpenTelemetry::Trace::Status.error("API Error: #{error_code}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Integrations::LlmInstrumentationHelpers
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
|
||||
private
|
||||
|
||||
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
|
||||
|
||||
def set_metadata_attributes(span, params)
|
||||
session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
|
||||
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
|
||||
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present?
|
||||
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
|
||||
|
||||
return unless params[:metadata].is_a?(Hash)
|
||||
|
||||
params[:metadata].each do |key, value|
|
||||
span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'opentelemetry_config'
|
||||
require_relative 'llm_instrumentation_constants'
|
||||
|
||||
module Integrations::LlmInstrumentationSpans
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
|
||||
def tracer
|
||||
@tracer ||= OpentelemetryConfig.tracer
|
||||
end
|
||||
|
||||
def start_llm_turn_span(params)
|
||||
return unless ChatwootApp.otel_enabled?
|
||||
|
||||
span = tracer.start_span(params[:span_name])
|
||||
set_llm_turn_request_attributes(span, params)
|
||||
set_llm_turn_prompt_attributes(span, params[:messages]) if params[:messages]
|
||||
|
||||
@pending_llm_turn_spans ||= []
|
||||
@pending_llm_turn_spans.push(span)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to start LLM turn span: #{e.message}"
|
||||
end
|
||||
|
||||
def end_llm_turn_span(message)
|
||||
return unless ChatwootApp.otel_enabled?
|
||||
|
||||
span = @pending_llm_turn_spans&.pop
|
||||
return unless span
|
||||
|
||||
set_llm_turn_response_attributes(span, message) if message
|
||||
span.finish
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to end LLM turn span: #{e.message}"
|
||||
end
|
||||
|
||||
def start_tool_span(tool_call)
|
||||
return unless ChatwootApp.otel_enabled?
|
||||
|
||||
tool_name = tool_call.name.to_s
|
||||
span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name))
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json)
|
||||
|
||||
@pending_tool_spans ||= []
|
||||
@pending_tool_spans.push(span)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to start tool span: #{e.message}"
|
||||
end
|
||||
|
||||
def end_tool_span(result)
|
||||
return unless ChatwootApp.otel_enabled?
|
||||
|
||||
span = @pending_tool_spans&.pop
|
||||
return unless span
|
||||
|
||||
output = result.is_a?(String) ? result : result.to_json
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, output)
|
||||
span.finish
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to end tool span: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_llm_turn_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]) if params[:model]
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
|
||||
end
|
||||
|
||||
def set_llm_turn_prompt_attributes(span, messages)
|
||||
messages.each_with_index do |msg, idx|
|
||||
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), msg[:role])
|
||||
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), msg[:content])
|
||||
end
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, messages.to_json)
|
||||
end
|
||||
|
||||
def set_llm_turn_response_attributes(span, message)
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, message.role.to_s) if message.respond_to?(:role)
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message.content.to_s) if message.respond_to?(:content)
|
||||
set_llm_turn_usage_attributes(span, message)
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, message.content.to_s) if message.respond_to?(:content)
|
||||
end
|
||||
|
||||
def set_llm_turn_usage_attributes(span, message)
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, message.input_tokens) if message.respond_to?(:input_tokens) && message.input_tokens
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, message.output_tokens) if message.respond_to?(:output_tokens) && message.output_tokens
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Integrations::Openai::ProcessorService < Integrations::OpenaiBaseService
|
||||
class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
|
||||
AGENT_INSTRUCTION = 'You are a helpful support agent.'.freeze
|
||||
LANGUAGE_INSTRUCTION = 'Ensure that the reply should be in user language.'.freeze
|
||||
def reply_suggestion_message
|
||||
|
||||
@@ -121,8 +121,6 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
||||
end
|
||||
|
||||
def upload_files
|
||||
return unless message.attachments.any?
|
||||
|
||||
files = build_files_array
|
||||
return if files.empty?
|
||||
|
||||
@@ -136,6 +134,8 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
||||
Rails.logger.info "slack_upload_result: #{result}"
|
||||
rescue Slack::Web::Api::Errors::SlackError => e
|
||||
Rails.logger.error "Failed to upload files: #{e.message}"
|
||||
ensure
|
||||
files.each { |file| file[:content]&.clear }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -143,14 +143,31 @@ class Integrations::Slack::SendOnSlackService < Base::SendOnChannelService
|
||||
message.attachments.filter_map do |attachment|
|
||||
next unless attachment.with_attached_file?
|
||||
|
||||
{
|
||||
filename: attachment.file.filename.to_s,
|
||||
content: attachment.file.download,
|
||||
title: attachment.file.filename.to_s
|
||||
}
|
||||
build_file_payload(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def build_file_payload(attachment)
|
||||
content = download_attachment_content(attachment)
|
||||
return if content.blank?
|
||||
|
||||
{
|
||||
filename: attachment.file.filename.to_s,
|
||||
content: content,
|
||||
title: attachment.file.filename.to_s
|
||||
}
|
||||
end
|
||||
|
||||
def download_attachment_content(attachment)
|
||||
buffer = +''
|
||||
attachment.file.blob.open do |file|
|
||||
while (chunk = file.read(64.kilobytes))
|
||||
buffer << chunk
|
||||
end
|
||||
end
|
||||
buffer
|
||||
end
|
||||
|
||||
def sender_name(sender)
|
||||
sender.try(:name) ? "#{sender.try(:name)} (#{sender_type(sender)})" : sender_type(sender)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
require 'ruby_llm'
|
||||
|
||||
module Llm::Config
|
||||
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
|
||||
class << self
|
||||
def initialized?
|
||||
@initialized ||= false
|
||||
end
|
||||
|
||||
def initialize!
|
||||
return if @initialized
|
||||
|
||||
configure_ruby_llm
|
||||
@initialized = true
|
||||
end
|
||||
|
||||
def reset!
|
||||
@initialized = false
|
||||
end
|
||||
|
||||
def with_api_key(api_key, api_base: nil)
|
||||
context = RubyLLM.context do |config|
|
||||
config.openai_api_key = api_key
|
||||
config.openai_api_base = api_base
|
||||
end
|
||||
|
||||
yield context
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def configure_ruby_llm
|
||||
RubyLLM.configure do |config|
|
||||
config.openai_api_key = system_api_key if system_api_key.present?
|
||||
config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present?
|
||||
config.logger = Rails.logger
|
||||
end
|
||||
end
|
||||
|
||||
def system_api_key
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
|
||||
end
|
||||
|
||||
def openai_endpoint
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,11 +2,14 @@
|
||||
# NOTE: are sensitive to local FS writes, and besides -- it's just not proper
|
||||
# NOTE: to have a dev-mode tool do its thing in production.
|
||||
if Rails.env.development?
|
||||
require 'annotate'
|
||||
require 'annotate_rb'
|
||||
|
||||
AnnotateRb::Core.load_rake_tasks
|
||||
|
||||
task :set_annotation_options do
|
||||
# You can override any of these by setting an environment variable of the
|
||||
# same name.
|
||||
Annotate.set_defaults(
|
||||
AnnotateRb::Options.set_defaults(
|
||||
'additional_file_patterns' => [],
|
||||
'routes' => 'false',
|
||||
'models' => 'true',
|
||||
@@ -55,6 +58,4 @@ if Rails.env.development?
|
||||
'with_comment' => 'true'
|
||||
)
|
||||
end
|
||||
|
||||
Annotate.load_tasks
|
||||
end
|
||||
|
||||
+5
-5
@@ -37,7 +37,7 @@
|
||||
"dependencies": {
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.2.6",
|
||||
"@chatwoot/utils": "^0.0.51",
|
||||
"@formkit/core": "^1.6.7",
|
||||
"@formkit/vue": "^1.6.7",
|
||||
@@ -49,7 +49,7 @@
|
||||
"@rails/actioncable": "6.1.3",
|
||||
"@rails/ujs": "^7.1.400",
|
||||
"@scmmishra/pico-search": "0.5.4",
|
||||
"@sentry/vue": "^8.31.0",
|
||||
"@sentry/vue": "^8.55.0",
|
||||
"@sindresorhus/slugify": "2.2.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tanstack/vue-table": "^8.20.5",
|
||||
@@ -60,7 +60,7 @@
|
||||
"@vueuse/components": "^12.0.0",
|
||||
"@vueuse/core": "^12.0.0",
|
||||
"activestorage": "^5.2.6",
|
||||
"axios": "^1.8.2",
|
||||
"axios": "^1.13.2",
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"chart.js": "~4.4.4",
|
||||
"color2k": "^2.0.2",
|
||||
@@ -137,8 +137,8 @@
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"histoire": "0.17.15",
|
||||
"husky": "^7.0.0",
|
||||
"jsdom": "^24.1.3",
|
||||
"lint-staged": "14.0.1",
|
||||
"jsdom": "^27.2.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-preset-env": "^8.5.1",
|
||||
"prettier": "^3.3.3",
|
||||
|
||||
Generated
+413
-514
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
require 'rubocop'
|
||||
|
||||
module RuboCop::Cop::Chatwoot; end
|
||||
|
||||
class RuboCop::Cop::Chatwoot::AttachmentDownload < RuboCop::Cop::Base
|
||||
MSG = 'Avoid calling `.file/.blob.download`; use `blob.open` or streaming IO instead.'.freeze
|
||||
|
||||
def_node_matcher :unsafe_download?, <<~PATTERN
|
||||
(send (send _ {:file :blob}) :download ...)
|
||||
PATTERN
|
||||
|
||||
def on_send(node)
|
||||
return unless unsafe_download?(node)
|
||||
|
||||
add_offense(node.loc.selector, message: MSG)
|
||||
end
|
||||
end
|
||||
@@ -245,6 +245,78 @@ RSpec.describe 'Enterprise Billing APIs', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /enterprise/api/v1/accounts/{account.id}/topup_checkout' do
|
||||
let(:stripe_customer_id) { 'cus_test123' }
|
||||
let(:invoice_settings) { Struct.new(:default_payment_method).new('pm_test123') }
|
||||
let(:stripe_customer) { Struct.new(:invoice_settings, :default_source).new(invoice_settings, nil) }
|
||||
let(:stripe_invoice) { Struct.new(:id).new('inv_test123') }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', value: [
|
||||
{ 'name' => 'Hacker', 'product_id' => ['prod_hacker'], 'price_ids' => ['price_hacker'] },
|
||||
{ 'name' => 'Business', 'product_id' => ['prod_business'], 'price_ids' => ['price_business'] }
|
||||
])
|
||||
end
|
||||
|
||||
it 'returns unauthorized for unauthenticated user' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout", as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { credits: 1000 },
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
before do
|
||||
account.update!(
|
||||
custom_attributes: { plan_name: 'Business', stripe_customer_id: stripe_customer_id },
|
||||
limits: { 'captain_responses' => 1000 }
|
||||
)
|
||||
allow(Stripe::Customer).to receive(:retrieve).with(stripe_customer_id).and_return(stripe_customer)
|
||||
allow(Stripe::Invoice).to receive(:create).and_return(stripe_invoice)
|
||||
allow(Stripe::InvoiceItem).to receive(:create)
|
||||
allow(Stripe::Invoice).to receive(:finalize_invoice)
|
||||
allow(Stripe::Invoice).to receive(:pay)
|
||||
allow(Stripe::Billing::CreditGrant).to receive(:create)
|
||||
end
|
||||
|
||||
it 'successfully processes topup and returns correct response' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { credits: 1000 },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['credits']).to eq(1000)
|
||||
expect(json_response['amount']).to eq(20.0)
|
||||
expect(json_response['limits']['captain_responses']).to eq(2000)
|
||||
end
|
||||
|
||||
it 'returns error when credits parameter is missing' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'returns error for invalid credits amount' do
|
||||
post "/enterprise/api/v1/accounts/#{account.id}/topup_checkout",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { credits: 999 },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /enterprise/api/v1/accounts/{account.id}/toggle_deletion' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
|
||||
@@ -5,21 +5,39 @@ RSpec.describe Integrations::Openai::ProcessorService do
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:hook) { create(:integrations_hook, :openai, account: account) }
|
||||
let(:expected_headers) { { 'Authorization' => "Bearer #{hook.settings['api_key']}" } }
|
||||
let(:openai_response) do
|
||||
{
|
||||
'choices' => [
|
||||
{
|
||||
'message' => {
|
||||
'content' => 'This is a reply from openai.'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.to_json
|
||||
|
||||
# Mock RubyLLM objects
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context) }
|
||||
let(:mock_config) { OpenStruct.new }
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: 'This is a reply from openai.',
|
||||
input_tokens: nil,
|
||||
output_tokens: nil
|
||||
)
|
||||
end
|
||||
let(:mock_empty_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: '',
|
||||
input_tokens: nil,
|
||||
output_tokens: nil
|
||||
)
|
||||
end
|
||||
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
before do
|
||||
allow(RubyLLM).to receive(:context).and_yield(mock_config).and_return(mock_context)
|
||||
allow(mock_context).to receive(:chat).and_return(mock_chat)
|
||||
|
||||
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:add_message).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when event name is label_suggestion with labels with < 3 messages' do
|
||||
let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
@@ -49,21 +67,15 @@ RSpec.describe Integrations::Openai::ProcessorService do
|
||||
end
|
||||
|
||||
it 'returns the label suggestions' do
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: anything, headers: expected_headers)
|
||||
.to_return(status: 200, body: openai_response, headers: {})
|
||||
|
||||
result = subject.perform
|
||||
expect(result).to eq({ :message => 'This is a reply from openai.' })
|
||||
expect(result).to eq({ message: 'This is a reply from openai.' })
|
||||
end
|
||||
|
||||
it 'returns empty string if openai response is blank' do
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.with(body: anything, headers: expected_headers)
|
||||
.to_return(status: 200, body: '{}', headers: {})
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_empty_response)
|
||||
|
||||
result = subject.perform
|
||||
expect(result).to eq({ :message => '' })
|
||||
expect(result[:message]).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ RSpec.describe Captain::Copilot::ChatService do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
|
||||
let(:mock_openai_client) { instance_double(OpenAI::Client) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
|
||||
let!(:copilot_message) do
|
||||
create(
|
||||
@@ -20,13 +19,29 @@ RSpec.describe Captain::Copilot::ChatService do
|
||||
{ user_id: user.id, copilot_thread_id: copilot_thread.id, conversation_id: conversation.display_id }
|
||||
end
|
||||
|
||||
# RubyLLM mocks
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_response) do
|
||||
instance_double(RubyLLM::Message, content: '{ "content": "Hey", "reasoning": "Test reasoning", "reply_suggestion": false }')
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://api.openai.com/')
|
||||
allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
|
||||
allow(mock_openai_client).to receive(:chat).and_return({
|
||||
choices: [{ message: { content: '{ "content": "Hey" }' } }]
|
||||
}.with_indifferent_access)
|
||||
InstallationConfig.find_or_create_by(name: 'CAPTAIN_OPEN_AI_API_KEY') do |c|
|
||||
c.value = 'test-key'
|
||||
end
|
||||
|
||||
allow(RubyLLM).to receive(:chat).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_params).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_tool).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:add_message).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:on_new_message).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:on_end_message).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:on_tool_call).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:on_tool_result).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:messages).and_return([])
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
@@ -48,48 +63,6 @@ RSpec.describe Captain::Copilot::ChatService do
|
||||
expect(messages.second[:role]).to eq('system')
|
||||
expect(messages.second[:content]).to include(account.id.to_s)
|
||||
end
|
||||
|
||||
it 'initializes OpenAI client with configured endpoint' do
|
||||
expect(OpenAI::Client).to receive(:new).with(
|
||||
access_token: 'test-key',
|
||||
uri_base: 'https://api.openai.com/',
|
||||
log_errors: Rails.env.development?
|
||||
)
|
||||
|
||||
described_class.new(assistant, config)
|
||||
end
|
||||
|
||||
context 'when CAPTAIN_OPEN_AI_ENDPOINT is not configured' do
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy
|
||||
end
|
||||
|
||||
it 'uses default OpenAI endpoint' do
|
||||
expect(OpenAI::Client).to receive(:new).with(
|
||||
access_token: 'test-key',
|
||||
uri_base: 'https://api.openai.com/',
|
||||
log_errors: Rails.env.development?
|
||||
)
|
||||
|
||||
described_class.new(assistant, config)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when custom endpoint is configured' do
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT').update!(value: 'https://custom.azure.com/')
|
||||
end
|
||||
|
||||
it 'uses custom endpoint for OpenAI client' do
|
||||
expect(OpenAI::Client).to receive(:new).with(
|
||||
access_token: 'test-key',
|
||||
uri_base: 'https://custom.azure.com/',
|
||||
log_errors: Rails.env.development?
|
||||
)
|
||||
|
||||
described_class.new(assistant, config)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_response' do
|
||||
@@ -112,82 +85,19 @@ RSpec.describe Captain::Copilot::ChatService do
|
||||
end
|
||||
|
||||
it 'returns the response from request_chat_completion' do
|
||||
expect(service.generate_response('Hello')).to eq({ 'content' => 'Hey' })
|
||||
result = service.generate_response('Hello')
|
||||
|
||||
expect(result).to eq({ 'content' => 'Hey', 'reasoning' => 'Test reasoning', 'reply_suggestion' => false })
|
||||
end
|
||||
|
||||
context 'when response contains tool calls' do
|
||||
before do
|
||||
allow(mock_openai_client).to receive(:chat).and_return(
|
||||
{
|
||||
choices: [{ message: { 'tool_calls' => tool_calls } }]
|
||||
}.with_indifferent_access,
|
||||
{
|
||||
choices: [{ message: { content: '{ "content": "Tool response processed" }' } }]
|
||||
}.with_indifferent_access
|
||||
)
|
||||
end
|
||||
|
||||
context 'when tool call is valid' do
|
||||
let(:tool_calls) do
|
||||
[{
|
||||
'id' => 'call_123',
|
||||
'function' => {
|
||||
'name' => 'get_conversation',
|
||||
'arguments' => "{ \"conversation_id\": #{conversation.display_id} }"
|
||||
}
|
||||
}]
|
||||
end
|
||||
|
||||
it 'processes tool calls and appends them to messages' do
|
||||
result = service.generate_response("Find conversation #{conversation.id}")
|
||||
|
||||
expect(result).to eq({ 'content' => 'Tool response processed' })
|
||||
expect(service.messages).to include(
|
||||
{ role: 'assistant', tool_calls: tool_calls }
|
||||
)
|
||||
expect(service.messages).to include(
|
||||
{
|
||||
role: 'tool', tool_call_id: 'call_123', content: conversation.to_llm_text
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).to eq({ 'content' => 'Tool response processed' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when tool call is invalid' do
|
||||
let(:tool_calls) do
|
||||
[{
|
||||
'id' => 'call_123',
|
||||
'function' => {
|
||||
'name' => 'get_settings',
|
||||
'arguments' => '{}'
|
||||
}
|
||||
}]
|
||||
end
|
||||
|
||||
it 'handles invalid tool calls' do
|
||||
result = service.generate_response('Find settings')
|
||||
|
||||
expect(result).to eq({ 'content' => 'Tool response processed' })
|
||||
expect(service.messages).to include(
|
||||
{
|
||||
role: 'assistant', tool_calls: tool_calls
|
||||
}
|
||||
)
|
||||
expect(service.messages).to include(
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'call_123',
|
||||
content: 'Tool not available'
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
it 'increments response usage for the account' do
|
||||
expect do
|
||||
service.generate_response('Hello')
|
||||
end.to(change { account.reload.custom_attributes['captain_responses_usage'].to_i }.by(1))
|
||||
end
|
||||
end
|
||||
|
||||
describe '#setup_user' do
|
||||
describe 'user setup behavior' do
|
||||
it 'sets user when user_id is present in config' do
|
||||
service = described_class.new(assistant, { user_id: user.id })
|
||||
expect(service.user).to eq(user)
|
||||
@@ -199,7 +109,7 @@ RSpec.describe Captain::Copilot::ChatService do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#setup_message_history' do
|
||||
describe 'message history behavior' do
|
||||
context 'when copilot_thread_id is present' do
|
||||
it 'finds the copilot thread and sets previous history from it' do
|
||||
service = described_class.new(assistant, { copilot_thread_id: copilot_thread.id })
|
||||
@@ -227,7 +137,7 @@ RSpec.describe Captain::Copilot::ChatService do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_messages' do
|
||||
describe 'message building behavior' do
|
||||
it 'includes system message and account context' do
|
||||
service = described_class.new(assistant, {})
|
||||
messages = service.messages
|
||||
@@ -257,13 +167,9 @@ RSpec.describe Captain::Copilot::ChatService do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#persist_message' do
|
||||
describe 'message persistence behavior' do
|
||||
context 'when copilot_thread is present' do
|
||||
it 'creates a copilot message' do
|
||||
allow(mock_openai_client).to receive(:chat).and_return({
|
||||
choices: [{ message: { content: '{ "content": "Hey" }' } }]
|
||||
}.with_indifferent_access)
|
||||
|
||||
it 'creates a copilot message with the response' do
|
||||
expect do
|
||||
described_class.new(assistant, { copilot_thread_id: copilot_thread.id }).generate_response('Hello')
|
||||
end.to change(CopilotMessage, :count).by(1)
|
||||
@@ -276,10 +182,6 @@ RSpec.describe Captain::Copilot::ChatService do
|
||||
|
||||
context 'when copilot_thread is not present' do
|
||||
it 'does not create a copilot message' do
|
||||
allow(mock_openai_client).to receive(:chat).and_return({
|
||||
choices: [{ message: { content: '{ "content": "Hey" }' } }]
|
||||
}.with_indifferent_access)
|
||||
|
||||
expect do
|
||||
described_class.new(assistant, {}).generate_response('Hello')
|
||||
end.not_to(change(CopilotMessage, :count))
|
||||
|
||||
@@ -28,13 +28,14 @@ RSpec.describe Captain::Llm::PdfProcessingService do
|
||||
context 'when uploading PDF to OpenAI' do
|
||||
let(:mock_client) { instance_double(OpenAI::Client) }
|
||||
let(:pdf_content) { 'PDF content' }
|
||||
let(:blob_double) { instance_double(ActiveStorage::Blob) }
|
||||
let(:pdf_file) { instance_double(ActiveStorage::Attachment) }
|
||||
|
||||
before do
|
||||
allow(document).to receive(:openai_file_id).and_return(nil)
|
||||
|
||||
# Use a simple double for ActiveStorage since it's a complex Rails object
|
||||
pdf_file = double('pdf_file', download: pdf_content) # rubocop:disable RSpec/VerifiedDoubles
|
||||
allow(document).to receive(:pdf_file).and_return(pdf_file)
|
||||
allow(pdf_file).to receive(:blob).and_return(blob_double)
|
||||
allow(blob_double).to receive(:open).and_yield(StringIO.new(pdf_content))
|
||||
|
||||
allow(OpenAI::Client).to receive(:new).and_return(mock_client)
|
||||
# Use a simple double for OpenAI::Files as it may not be loaded
|
||||
|
||||
@@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do
|
||||
end
|
||||
|
||||
describe '#parameters' do
|
||||
it 'returns the expected parameter schema' do
|
||||
expect(service.parameters).to eq(
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
article_id: {
|
||||
type: 'number',
|
||||
description: 'The ID of the article to retrieve'
|
||||
}
|
||||
},
|
||||
required: %w[article_id]
|
||||
}
|
||||
)
|
||||
it 'defines article_id parameter' do
|
||||
expect(service.parameters.keys).to contain_exactly(:article_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,13 +68,13 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do
|
||||
describe '#execute' do
|
||||
context 'when article_id is blank' do
|
||||
it 'returns error message' do
|
||||
expect(service.execute({})).to eq('Missing required parameters')
|
||||
expect(service.execute(article_id: nil)).to eq('Article not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when article is not found' do
|
||||
it 'returns not found message' do
|
||||
expect(service.execute({ 'article_id' => 999 })).to eq('Article not found')
|
||||
expect(service.execute(article_id: 999)).to eq('Article not found')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -94,7 +83,7 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do
|
||||
let(:article) { create(:article, account: account, portal: portal, author: user, title: 'Test Article', content: 'Content') }
|
||||
|
||||
it 'returns the article in llm text format' do
|
||||
result = service.execute({ 'article_id' => article.id })
|
||||
result = service.execute(article_id: article.id)
|
||||
expect(result).to eq(article.to_llm_text)
|
||||
end
|
||||
|
||||
@@ -104,7 +93,7 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do
|
||||
let(:other_article) { create(:article, account: other_account, portal: other_portal, author: user, title: 'Other Article') }
|
||||
|
||||
it 'returns not found message' do
|
||||
expect(service.execute({ 'article_id' => other_article.id })).to eq('Article not found')
|
||||
expect(service.execute(article_id: other_article.id)).to eq('Article not found')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user