Merge branch 'develop' into chore/update-rails
This commit is contained in:
@@ -24,9 +24,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
def search
|
||||
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
|
||||
|
||||
contacts = resolved_contacts.where(
|
||||
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
|
||||
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
|
||||
contacts = Current.account.contacts.where(
|
||||
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
|
||||
search: "%#{params[:q].strip}%"
|
||||
)
|
||||
@contacts = fetch_contacts(contacts)
|
||||
|
||||
@@ -1271,7 +1271,7 @@ export default {
|
||||
key="copilot-bottom-panel"
|
||||
:is-generating-content="copilot.isButtonDisabled.value"
|
||||
@submit="onSubmitCopilotReply"
|
||||
@cancel="copilot.toggleEditor"
|
||||
@cancel="copilot.reset"
|
||||
/>
|
||||
<ReplyBottomPanel
|
||||
v-else
|
||||
|
||||
+4
@@ -515,6 +515,10 @@ const languages = [
|
||||
name: 'Portuguese',
|
||||
id: 'pt',
|
||||
},
|
||||
{
|
||||
name: 'Portuguese (Brazil)',
|
||||
id: 'pt_BR',
|
||||
},
|
||||
{
|
||||
name: 'Punjabi',
|
||||
id: 'pa',
|
||||
|
||||
+3
-3
@@ -13,7 +13,7 @@ import { mapGetters } from 'vuex';
|
||||
// utils & constants
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { OPEN_AI_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
import { CAPTAIN_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
name: 'LabelSuggestion',
|
||||
@@ -114,7 +114,7 @@ export default {
|
||||
|
||||
// dismiss this once the values are set
|
||||
this.isDismissed = true;
|
||||
this.trackLabelEvent(OPEN_AI_EVENTS.DISMISS_LABEL_SUGGESTION);
|
||||
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_DISMISSED);
|
||||
},
|
||||
isConversationDismissed() {
|
||||
return LocalStorage.getFlag(
|
||||
@@ -132,7 +132,7 @@ export default {
|
||||
conversationId: this.conversationId,
|
||||
labels: labelsToAdd,
|
||||
});
|
||||
this.trackLabelEvent(OPEN_AI_EVENTS.APPLY_LABEL_SUGGESTION);
|
||||
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_APPLIED);
|
||||
},
|
||||
trackLabelEvent(event) {
|
||||
const payload = {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables/useAccount');
|
||||
@@ -23,8 +22,8 @@ vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
|
||||
return actual;
|
||||
});
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
|
||||
OPEN_AI_EVENTS: {
|
||||
TEST_EVENT: 'open_ai_test_event',
|
||||
CAPTAIN_EVENTS: {
|
||||
TEST_EVENT: 'captain_test_event',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -65,17 +64,6 @@ describe('useCaptain', () => {
|
||||
expect(draftMessage.value).toBe('Draft message');
|
||||
});
|
||||
|
||||
it('records analytics correctly', async () => {
|
||||
const { recordAnalytics } = useCaptain();
|
||||
|
||||
await recordAnalytics('TEST_EVENT', { data: 'test' });
|
||||
|
||||
expect(analyticsHelper.track).toHaveBeenCalledWith('open_ai_test_event', {
|
||||
type: 'TEST_EVENT',
|
||||
data: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('rewrites content', async () => {
|
||||
TasksAPI.rewrite.mockResolvedValue({
|
||||
data: { message: 'Rewritten content', follow_up_context: { id: 'ctx1' } },
|
||||
|
||||
@@ -7,10 +7,9 @@ import {
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
|
||||
export function useCaptain() {
|
||||
@@ -79,23 +78,6 @@ export function useCaptain() {
|
||||
useAlert(errorMessage);
|
||||
};
|
||||
|
||||
// === Analytics ===
|
||||
/**
|
||||
* Records analytics for AI-related events.
|
||||
* @param {string} type - The type of event.
|
||||
* @param {Object} payload - Additional data for the event.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const recordAnalytics = async (type, payload) => {
|
||||
const event = OPEN_AI_EVENTS[type.toUpperCase()];
|
||||
if (event) {
|
||||
useTrack(event, {
|
||||
type,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// === Task Methods ===
|
||||
/**
|
||||
* Rewrites content with a specific operation.
|
||||
@@ -234,8 +216,5 @@ export function useCaptain() {
|
||||
getReplySuggestion,
|
||||
followUp,
|
||||
processEvent,
|
||||
|
||||
// Analytics
|
||||
recordAnalytics,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,56 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
// Actions that map to REWRITE events (with operation attribute)
|
||||
const REWRITE_ACTIONS = [
|
||||
'improve',
|
||||
'fix_spelling_grammar',
|
||||
'casual',
|
||||
'professional',
|
||||
'expand',
|
||||
'shorten',
|
||||
'rephrase',
|
||||
'make_friendly',
|
||||
'make_formal',
|
||||
'simplify',
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets the event key suffix based on action type.
|
||||
* @param {string} action - The action type
|
||||
* @returns {string} The event key prefix (REWRITE, SUMMARIZE, or REPLY_SUGGESTION)
|
||||
*/
|
||||
function getEventPrefix(action) {
|
||||
if (action === 'summarize') return 'SUMMARIZE';
|
||||
if (action === 'reply_suggestion') return 'REPLY_SUGGESTION';
|
||||
return 'REWRITE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the analytics payload based on action type.
|
||||
* @param {string} action - The action type
|
||||
* @param {number} conversationId - The conversation ID
|
||||
* @param {number} [followUpCount] - Optional follow-up count
|
||||
* @returns {Object} The payload object
|
||||
*/
|
||||
function buildPayload(action, conversationId, followUpCount = undefined) {
|
||||
const payload = { conversationId };
|
||||
|
||||
// Add operation for rewrite actions
|
||||
if (REWRITE_ACTIONS.includes(action)) {
|
||||
payload.operation = action;
|
||||
}
|
||||
|
||||
// Add followUpCount if provided
|
||||
if (followUpCount !== undefined) {
|
||||
payload.followUpCount = followUpCount;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing Copilot reply generation state and actions.
|
||||
@@ -9,7 +59,7 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
* @returns {Object} Copilot reply state and methods
|
||||
*/
|
||||
export function useCopilotReply() {
|
||||
const { processEvent, followUp } = useCaptain();
|
||||
const { processEvent, followUp, currentChat } = useCaptain();
|
||||
const { updateUISettings } = useUISettings();
|
||||
|
||||
const showEditor = ref(false);
|
||||
@@ -19,6 +69,13 @@ export function useCopilotReply() {
|
||||
const followUpContext = ref(null);
|
||||
const abortController = ref(null);
|
||||
|
||||
// Tracking state
|
||||
const currentAction = ref(null);
|
||||
const followUpCount = ref(0);
|
||||
const trackedConversationId = ref(null);
|
||||
|
||||
const conversationId = computed(() => currentChat.value?.id);
|
||||
|
||||
const isActive = computed(() => showEditor.value || isGenerating.value);
|
||||
const isButtonDisabled = computed(
|
||||
() => isGenerating.value || !isContentReady.value
|
||||
@@ -29,8 +86,22 @@ export function useCopilotReply() {
|
||||
|
||||
/**
|
||||
* Resets all copilot editor state and cancels any ongoing generation.
|
||||
* @param {boolean} [trackDismiss=true] - Whether to track dismiss event
|
||||
*/
|
||||
function reset() {
|
||||
function reset(trackDismiss = true) {
|
||||
// Track dismiss event if there was content and we're not accepting
|
||||
if (trackDismiss && generatedContent.value && currentAction.value) {
|
||||
const eventKey = `${getEventPrefix(currentAction.value)}_DISMISSED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(
|
||||
currentAction.value,
|
||||
trackedConversationId.value,
|
||||
followUpCount.value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (abortController.value) {
|
||||
abortController.value.abort();
|
||||
abortController.value = null;
|
||||
@@ -40,6 +111,9 @@ export function useCopilotReply() {
|
||||
isContentReady.value = false;
|
||||
generatedContent.value = '';
|
||||
followUpContext.value = null;
|
||||
currentAction.value = null;
|
||||
followUpCount.value = 0;
|
||||
trackedConversationId.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,11 +144,14 @@ export function useCopilotReply() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset and start new generation
|
||||
reset();
|
||||
// Reset without tracking dismiss (starting new action)
|
||||
reset(false);
|
||||
abortController.value = new AbortController();
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
currentAction.value = action;
|
||||
followUpCount.value = 0;
|
||||
trackedConversationId.value = conversationId.value;
|
||||
|
||||
try {
|
||||
const { message: content, followUpContext: newContext } =
|
||||
@@ -85,7 +162,15 @@ export function useCopilotReply() {
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = newContext;
|
||||
if (content) showEditor.value = true;
|
||||
if (content) {
|
||||
showEditor.value = true;
|
||||
// Track "Used" event on successful generation
|
||||
const eventKey = `${getEventPrefix(action)}_USED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(action, trackedConversationId.value)
|
||||
);
|
||||
}
|
||||
isGenerating.value = false;
|
||||
}
|
||||
} catch {
|
||||
@@ -106,6 +191,12 @@ export function useCopilotReply() {
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
|
||||
// Track follow-up sent event
|
||||
useTrack(CAPTAIN_EVENTS.FOLLOW_UP_SENT, {
|
||||
conversationId: trackedConversationId.value,
|
||||
});
|
||||
followUpCount.value += 1;
|
||||
|
||||
try {
|
||||
const { message: content, followUpContext: updatedContext } =
|
||||
await followUp({
|
||||
@@ -137,7 +228,28 @@ export function useCopilotReply() {
|
||||
*/
|
||||
function accept() {
|
||||
const content = generatedContent.value;
|
||||
|
||||
// Track "Applied" event
|
||||
if (currentAction.value) {
|
||||
const eventKey = `${getEventPrefix(currentAction.value)}_APPLIED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(
|
||||
currentAction.value,
|
||||
trackedConversationId.value,
|
||||
followUpCount.value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Reset state without tracking dismiss
|
||||
showEditor.value = false;
|
||||
generatedContent.value = '';
|
||||
followUpContext.value = null;
|
||||
currentAction.value = null;
|
||||
followUpCount.value = 0;
|
||||
trackedConversationId.value = null;
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
@@ -84,22 +84,28 @@ export const PORTALS_EVENTS = Object.freeze({
|
||||
PREVIEW_ARTICLE: 'Previewed article',
|
||||
});
|
||||
|
||||
export const OPEN_AI_EVENTS = Object.freeze({
|
||||
SUMMARIZE: 'OpenAI: Used summarize',
|
||||
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
|
||||
REPHRASE: 'OpenAI: Used rephrase',
|
||||
IMPROVE: 'OpenAI: Used improve',
|
||||
FIX_SPELLING_AND_GRAMMAR: 'OpenAI: Used fix spelling and grammar',
|
||||
SHORTEN: 'OpenAI: Used shorten',
|
||||
EXPAND: 'OpenAI: Used expand',
|
||||
MAKE_FRIENDLY: 'OpenAI: Used make friendly',
|
||||
MAKE_FORMAL: 'OpenAI: Used make formal',
|
||||
SIMPLIFY: 'OpenAI: Used simplify',
|
||||
APPLY_LABEL_SUGGESTION: 'OpenAI: Apply label from suggestion',
|
||||
DISMISS_LABEL_SUGGESTION: 'OpenAI: Dismiss label suggestions',
|
||||
ADDED_AI_INTEGRATION_VIA_CTA_BUTTON:
|
||||
'OpenAI: Added AI integration via CTA button',
|
||||
DISMISS_AI_SUGGESTION: 'OpenAI: Dismiss AI suggestions',
|
||||
export const CAPTAIN_EVENTS = Object.freeze({
|
||||
// Rewrite events (with operation attribute in payload)
|
||||
REWRITE_USED: 'Captain: Rewrite used',
|
||||
REWRITE_APPLIED: 'Captain: Rewrite applied',
|
||||
REWRITE_DISMISSED: 'Captain: Rewrite dismissed',
|
||||
|
||||
// Summarize events
|
||||
SUMMARIZE_USED: 'Captain: Summarize used',
|
||||
SUMMARIZE_APPLIED: 'Captain: Summarize applied',
|
||||
SUMMARIZE_DISMISSED: 'Captain: Summarize dismissed',
|
||||
|
||||
// Reply suggestion events
|
||||
REPLY_SUGGESTION_USED: 'Captain: Reply suggestion used',
|
||||
REPLY_SUGGESTION_APPLIED: 'Captain: Reply suggestion applied',
|
||||
REPLY_SUGGESTION_DISMISSED: 'Captain: Reply suggestion dismissed',
|
||||
|
||||
// Follow-up events
|
||||
FOLLOW_UP_SENT: 'Captain: Follow-up sent',
|
||||
|
||||
// Label suggestions
|
||||
LABEL_SUGGESTION_APPLIED: 'Captain: Label suggestion applied',
|
||||
LABEL_SUGGESTION_DISMISSED: 'Captain: Label suggestion dismissed',
|
||||
});
|
||||
|
||||
export const COPILOT_EVENTS = Object.freeze({
|
||||
|
||||
@@ -51,7 +51,6 @@ class Messages::SearchDataPresenter < SimpleDelegator
|
||||
|
||||
def additional_attributes_data
|
||||
{
|
||||
campaign_id: additional_attributes&.dig('campaign_id'),
|
||||
automation_rule_id: content_attributes&.dig('automation_rule_id')
|
||||
}
|
||||
end
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
|
||||
def initialize
|
||||
super
|
||||
@list_item_number = 0
|
||||
end
|
||||
|
||||
def strong(_node)
|
||||
out('*', :children, '*')
|
||||
end
|
||||
@@ -15,13 +20,20 @@ class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderer
|
||||
out(node.url)
|
||||
end
|
||||
|
||||
def list(_node)
|
||||
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)
|
||||
out('- ', :children)
|
||||
if @list_type == :ordered_list
|
||||
out("#{@list_item_number}. ", :children)
|
||||
@list_item_number += 1
|
||||
else
|
||||
out('- ', :children)
|
||||
end
|
||||
cr
|
||||
end
|
||||
|
||||
|
||||
@@ -357,18 +357,6 @@ RSpec.describe 'Contacts API', type: :request do
|
||||
expect(response.body).not_to include(contact1.email)
|
||||
end
|
||||
|
||||
it 'searches contacts using company name' do
|
||||
contact2.update(additional_attributes: { company_name: 'acme.inc' })
|
||||
get "/api/v1/accounts/#{account.id}/contacts/search",
|
||||
params: { q: 'acme.inc' },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).to include(contact2.email)
|
||||
expect(response.body).not_to include(contact1.email)
|
||||
end
|
||||
|
||||
it 'matches the resolved contact respecting the identifier character casing' do
|
||||
contact_normal = create(:contact, name: 'testcontact', account: account, identifier: 'testidentifer')
|
||||
contact_special = create(:contact, name: 'testcontact', account: account, identifier: 'TestIdentifier')
|
||||
|
||||
@@ -61,15 +61,10 @@ RSpec.describe Messages::SearchDataPresenter do
|
||||
context 'with campaign and automation data' do
|
||||
before do
|
||||
message.update(
|
||||
additional_attributes: { 'campaign_id' => '123' },
|
||||
content_attributes: { 'automation_rule_id' => '456' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes campaign_id' do
|
||||
expect(presenter.search_data[:additional_attributes][:campaign_id]).to eq('123')
|
||||
end
|
||||
|
||||
it 'includes automation_rule_id' do
|
||||
expect(presenter.search_data[:additional_attributes][:automation_rule_id]).to eq('456')
|
||||
end
|
||||
|
||||
@@ -53,12 +53,28 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
|
||||
expect(result.strip).to eq('*bold _italic_*')
|
||||
end
|
||||
|
||||
it 'converts bullet lists' do
|
||||
content = "- item 1\n- item 2"
|
||||
it 'preserves unordered list with dash markers' do
|
||||
content = "- item 1\n- item 2\n- item 3"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result.strip).to include('- item 1')
|
||||
expect(result.strip).to include('- item 2')
|
||||
expect(result).to include("- item 1\n- item 2")
|
||||
expect(result).to include('- item 1')
|
||||
expect(result).to include('- item 2')
|
||||
expect(result).to include('- item 3')
|
||||
end
|
||||
|
||||
it 'converts asterisk unordered lists to dash markers' do
|
||||
content = "* item 1\n* item 2\n* item 3"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('- item 1')
|
||||
expect(result).to include('- item 2')
|
||||
expect(result).to include('- item 3')
|
||||
end
|
||||
|
||||
it 'preserves ordered list markers with numbering' do
|
||||
content = "1. first step\n2. second step\n3. third step"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('1. first step')
|
||||
expect(result).to include('2. second step')
|
||||
expect(result).to include('3. third step')
|
||||
end
|
||||
|
||||
it 'preserves newlines in plain text without list markers' do
|
||||
@@ -432,6 +448,24 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
|
||||
expect(result).to include("Line 1\nLine 2\nLine 3")
|
||||
end
|
||||
|
||||
it 'preserves ordered list markers with numbering in Twilio WhatsApp' do
|
||||
content = "1. first step\n2. second step\n3. third step"
|
||||
channel = instance_double(Channel::TwilioSms, whatsapp?: true)
|
||||
result = described_class.new(content, channel_type, channel).render
|
||||
expect(result).to include('1. first step')
|
||||
expect(result).to include('2. second step')
|
||||
expect(result).to include('3. third step')
|
||||
end
|
||||
|
||||
it 'preserves unordered list markers in Twilio WhatsApp' do
|
||||
content = "- item 1\n- item 2\n- item 3"
|
||||
channel = instance_double(Channel::TwilioSms, whatsapp?: true)
|
||||
result = described_class.new(content, channel_type, channel).render
|
||||
expect(result).to include('- item 1')
|
||||
expect(result).to include('- item 2')
|
||||
expect(result).to include('- item 3')
|
||||
end
|
||||
|
||||
it 'backwards compatible when channel is not provided' do
|
||||
content = '**bold** _italic_'
|
||||
result = described_class.new(content, channel_type).render
|
||||
@@ -483,12 +517,12 @@ RSpec.describe Messages::MarkdownRendererService, type: :service do
|
||||
context 'when testing all formatting types' do
|
||||
let(:channel_type) { 'Channel::Whatsapp' }
|
||||
|
||||
it 'handles ordered lists' do
|
||||
it 'handles ordered lists with proper numbering' do
|
||||
content = "1. first\n2. second\n3. third"
|
||||
result = described_class.new(content, channel_type).render
|
||||
expect(result).to include('first')
|
||||
expect(result).to include('second')
|
||||
expect(result).to include('third')
|
||||
expect(result).to include('1. first')
|
||||
expect(result).to include('2. second')
|
||||
expect(result).to include('3. third')
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user