Compare commits

...
Author SHA1 Message Date
Shivam MishraandGitHub 3e75ac2de5 feat: update the UX around label suggestions (#7532) 2023-07-17 15:10:56 +05:30
91c1061214 feat: Add more AI options (#7502)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-07-16 12:25:16 -07:00
ec65b43993 feat: Add support for API key authentication in Twilio (#7523)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-07-14 19:20:54 -07:00
Shivam MishraandGitHub 1a81245987 feat: Update label suggestion visibility (#7525) 2023-07-14 19:20:03 -07:00
Pranav Raj SandGitHub 2c3ad73542 fix: Update the case for validation where SSO link is used (#7527) 2023-07-14 08:30:08 -07:00
Shivam MishraandGitHub 59b1d59574 fix: idb is not available in firefox private mode [CW-2217] (#7524) 2023-07-14 13:35:30 +05:30
Pranav Raj SandGitHub 9de2edd300 fix: Cannot read properties of undefined (reading 'filter') (#7522) 2023-07-13 15:56:28 -07:00
Pranav Raj SandGitHub 66751b71dd fix: Fix issue null is not iterable (#7521) 2023-07-13 15:47:33 -07:00
Pranav Raj SandGitHub 7e415fbee3 fix: Disable predictions if model is absent (#7520) 2023-07-13 14:40:16 -07:00
Muhsin KelothandGitHub 50a927bac2 feat: Add reply editor mode to the store (#7515) 2023-07-13 14:56:53 +05:30
Muhsin KelothandGitHub 19ff738211 feat: Add more options for AI reply suggestions (#7493) 2023-07-13 10:26:25 +05:30
Shivam MishraandGitHub 7c080fa9fa feat: label suggestion UI (#7480) 2023-07-13 09:16:09 +05:30
91e2da5e74 fix: Uncategorized article display error - HelpCenter-> Portal -> Uncategorised Article (#7504)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-07-12 16:34:16 -07:00
Pranav Raj SandGitHub 21691f2407 fix: Update breaking specs on sentiment and open AI integration (#7513) 2023-07-12 14:43:11 -07:00
d495411ef5 feat: Minor improvements to login UI (#7494)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-07-12 13:35:02 -07:00
918e1f25c2 feat: Improve summary prompt (#7510)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-07-12 13:09:19 -07:00
Tejaswini ChileandGitHub 10dd0ba647 feat: Sentiment Analysis (#7475) 2023-07-12 15:03:31 +05:30
Pranav Raj SandGitHub 550bea099c fix: Add appearance-none to input field for ring to work (#7507) 2023-07-11 19:07:02 -07:00
Nithin David ThomasandGitHub c7bf93ef8a fix: Adds a space after mention to improve UX (#7506) 2023-07-11 17:37:37 -07:00
Shivam MishraandGitHub ec7434d000 feat: Remove twitter (#7505) 2023-07-11 14:00:11 -07:00
Muhsin KelothandGitHub 941e7e06cf feat: Add draft messages store (#7476) 2023-07-11 18:29:42 +05:30
Pranav Raj SandGitHub 09f46aa912 chore: Update onMessage event to include conversation id (#7497) 2023-07-10 15:04:31 -07:00
Vishnu NarayananandGitHub 332ab5888c feat: Disable fb/instagram if env variables are not configured (#7498) 2023-07-10 12:28:55 -07:00
71 changed files with 2228 additions and 473 deletions
+3
View File
@@ -230,3 +230,6 @@ AZURE_APP_SECRET=
## Change these values to fine tune performance
# control the concurrency setting of sidekiq
# SIDEKIQ_CONCURRENCY=10
# Sentiment analysis model file path
SENTIMENT_FILE_PATH=
+3
View File
@@ -165,6 +165,9 @@ gem 'omniauth'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
# Sentiment analysis
gem 'informers'
### Gems required only in specific deployment environments ###
##############################################################
+15
View File
@@ -145,6 +145,7 @@ GEM
statsd-ruby (~> 1.1)
bcrypt (3.1.18)
bindex (0.8.1)
blingfire (0.1.8)
bootsnap (1.16.0)
msgpack (~> 1.2)
brakeman (5.4.1)
@@ -361,6 +362,10 @@ GEM
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
informers (0.2.0)
blingfire (>= 0.1.7)
numo-narray
onnxruntime (>= 0.5.1)
jbuilder (2.11.5)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
@@ -474,6 +479,7 @@ GEM
racc (~> 1.4)
nokogiri (1.15.2-x86_64-linux)
racc (~> 1.4)
numo-narray (0.9.2.1)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
snaky_hash (~> 2.0)
@@ -502,6 +508,14 @@ GEM
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
onnxruntime (0.7.6)
ffi
onnxruntime (0.7.6-arm64-darwin)
ffi
onnxruntime (0.7.6-x86_64-darwin)
ffi
onnxruntime (0.7.6-x86_64-linux)
ffi
openssl (3.1.0)
orm_adapter (0.5.0)
os (1.1.4)
@@ -846,6 +860,7 @@ DEPENDENCIES
hashie
html2text!
image_processing
informers
jbuilder
json_refs
json_schemer
@@ -18,7 +18,11 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
end
def authenticate_twilio
client = Twilio::REST::Client.new(permitted_params[:account_sid], permitted_params[:auth_token])
client = if permitted_params[:api_key_sid].present?
Twilio::REST::Client.new(permitted_params[:api_key_sid], permitted_params[:auth_token], permitted_params[:account_sid])
else
Twilio::REST::Client.new(permitted_params[:account_sid], permitted_params[:auth_token])
end
client.messages.list(limit: 1)
end
@@ -40,6 +44,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
@twilio_channel = Current.account.twilio_sms.create!(
account_sid: permitted_params[:account_sid],
auth_token: permitted_params[:auth_token],
api_key_sid: permitted_params[:api_key_sid],
messaging_service_sid: permitted_params[:messaging_service_sid].presence,
phone_number: phone_number,
medium: medium
@@ -52,7 +57,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def permitted_params
params.require(:twilio_channel).permit(
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
)
end
end
@@ -18,6 +18,10 @@ class CacheEnabledApiClient extends ApiClient {
return this.getFromCache();
}
return this.getFromNetwork();
}
getFromNetwork() {
return axios.get(this.url);
}
@@ -32,7 +36,12 @@ class CacheEnabledApiClient extends ApiClient {
}
async getFromCache() {
await this.dataManager.initDb();
try {
// IDB is not supported in Firefox private mode: https://bugzilla.mozilla.org/show_bug.cgi?id=781982
await this.dataManager.initDb();
} catch {
return this.getFromNetwork();
}
const { data } = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
@@ -55,16 +64,22 @@ class CacheEnabledApiClient extends ApiClient {
}
async refetchAndCommit(newKey = null) {
await this.dataManager.initDb();
const response = await axios.get(this.url);
this.dataManager.replace({
modelName: this.cacheModelName,
data: this.extractDataFromResponse(response),
});
const response = await this.getFromNetwork();
await this.dataManager.setCacheKeys({
[this.cacheModelName]: newKey,
});
try {
await this.dataManager.initDb();
this.dataManager.replace({
modelName: this.cacheModelName,
data: this.extractDataFromResponse(response),
});
await this.dataManager.setCacheKeys({
[this.cacheModelName]: newKey,
});
} catch {
// Ignore error
}
return response;
}
@@ -4,7 +4,7 @@
<fluent-icon :icon="icon" size="12" class="label--icon" />
</span>
<span
v-if="variant === 'smooth' && title && !icon"
v-if="['smooth', 'dashed'].includes(variant) && title && !icon"
:style="{ background: color }"
class="label-color-dot"
/>
@@ -69,6 +69,7 @@ export default {
computed: {
textColor() {
if (this.variant === 'smooth') return '';
if (this.variant === 'dashed') return '';
return this.color || getContrastingTextColor(this.bgColor);
},
labelClass() {
@@ -199,8 +200,14 @@ export default {
&.smooth {
background: transparent;
border: 1px solid var(--s-100);
color: var(--s-700);
border: 1px solid var(--s-100);
}
&.dashed {
background: transparent;
color: var(--s-700);
border: 1px dashed var(--s-100);
}
}
@@ -1,145 +1,53 @@
<template>
<div v-if="isAIIntegrationEnabled" class="position-relative">
<div v-if="!message">
<woot-button
v-if="isPrivateNote"
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.SUMMARY_TITLE')"
icon="book-pulse"
color-scheme="secondary"
variant="smooth"
size="small"
:is-loading="uiFlags.summarize"
@click="processEvent('summarize')"
<woot-button
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
icon="wand"
color-scheme="secondary"
variant="smooth"
size="small"
@click="openAIAssist"
/>
<woot-modal
:show.sync="showAIAssistanceModal"
:on-close="hideAIAssistanceModal"
>
<AIAssistanceModal
:ai-option="aiOption"
@apply-text="insertText"
@close="hideAIAssistanceModal"
/>
<woot-button
v-else
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_TITLE')"
icon="wand"
color-scheme="secondary"
variant="smooth"
size="small"
:is-loading="uiFlags.reply_suggestion"
@click="processEvent('reply_suggestion')"
/>
</div>
<div v-else>
<woot-button
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.TITLE')"
icon="text-grammar-wand"
color-scheme="secondary"
variant="smooth"
size="small"
@click="toggleDropdown"
/>
<div
v-if="showDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open ai-modal"
>
<h4 class="sub-block-title margin-top-1">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TITLE') }}
</h4>
<p>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.SUBTITLE') }}
</p>
<label>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TONE.TITLE') }}
</label>
<div class="tone__item">
<select v-model="activeTone" class="status--filter small">
<option v-for="tone in tones" :key="tone.key" :value="tone.key">
{{ tone.value }}
</option>
</select>
</div>
<div class="modal-footer flex-container align-right">
<woot-button variant="clear" size="small" @click="closeDropdown">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.CANCEL') }}
</woot-button>
<woot-button
:is-loading="uiFlags.rephrase"
size="small"
@click="processEvent('rephrase')"
>
{{ buttonText }}
</woot-button>
</div>
</div>
</div>
</woot-modal>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import OpenAPI from 'dashboard/api/integrations/openapi';
import alertMixin from 'shared/mixins/alertMixin';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import AIAssistanceModal from './AIAssistanceModal.vue';
import aiMixin from 'dashboard/mixins/aiMixin';
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
export default {
mixins: [alertMixin, clickaway, eventListenerMixins],
props: {
conversationId: {
type: Number,
default: 0,
},
message: {
type: String,
default: '',
},
isPrivateNote: {
type: Boolean,
default: false,
},
},
data() {
return {
uiFlags: {
rephrase: false,
reply_suggestion: false,
summarize: false,
},
showDropdown: false,
activeTone: 'professional',
initialMessage: '',
tones: [
{
key: 'professional',
value: this.$t(
'INTEGRATION_SETTINGS.OPEN_AI.TONE.OPTIONS.PROFESSIONAL'
),
},
{
key: 'friendly',
value: this.$t('INTEGRATION_SETTINGS.OPEN_AI.TONE.OPTIONS.FRIENDLY'),
},
],
};
components: {
AIAssistanceModal,
},
mixins: [aiMixin, eventListenerMixins],
data: () => ({
showAIAssistanceModal: false,
aiOption: '',
initialMessage: '',
}),
computed: {
...mapGetters({ appIntegrations: 'integrations/getAppIntegrations' }),
isAIIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
);
},
hookId() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
).hooks[0].id;
},
buttonText() {
return this.uiFlags.isRephrasing
? this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATING')
: this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATE');
},
...mapGetters({
currentChat: 'getSelectedChat',
}),
},
mounted() {
if (!this.appIntegrations.length) {
this.$store.dispatch('integrations/get');
}
bus.$on(CMD_AI_ASSIST, this.onAIAssist);
this.initialMessage = this.draftMessage;
},
methods: {
@@ -147,85 +55,26 @@ export default {
const keyPattern = buildHotKeys(event);
const shouldRevertTheContent =
['meta+z', 'ctrl+z'].includes(keyPattern) && !!this.initialMessage;
if (shouldRevertTheContent) {
this.$emit('replace-text', this.initialMessage);
this.initialMessage = '';
}
},
toggleDropdown() {
this.showDropdown = !this.showDropdown;
hideAIAssistanceModal() {
this.showAIAssistanceModal = false;
},
closeDropdown() {
this.showDropdown = false;
openAIAssist() {
this.initialMessage = this.draftMessage;
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'ai_assist' });
},
async recordAnalytics({ type, tone }) {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
this.$track(event, {
type,
tone,
});
}
onAIAssist(option) {
this.aiOption = option;
this.showAIAssistanceModal = true;
},
async processEvent(type = 'rephrase') {
this.uiFlags[type] = true;
try {
const result = await OpenAPI.processEvent({
hookId: this.hookId,
type,
content: this.message,
tone: this.activeTone,
conversationId: this.conversationId,
});
const {
data: { message: generatedMessage },
} = result;
this.initialMessage = this.message;
this.$emit('replace-text', generatedMessage || this.message);
this.closeDropdown();
this.recordAnalytics({ type, tone: this.activeTone });
} catch (error) {
this.showAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'));
} finally {
this.uiFlags[type] = false;
}
insertText(message) {
this.$emit('replace-text', message);
},
},
};
</script>
<style lang="scss" scoped>
.ai-modal {
width: 400px;
right: 0;
left: 0;
padding: var(--space-normal);
bottom: 34px;
position: absolute;
span {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
}
p {
color: var(--s-600);
}
label {
margin-bottom: var(--space-smaller);
}
.status--filter {
background-color: var(--color-background-light);
border: 1px solid var(--color-border);
font-size: var(--font-size-small);
height: var(--space-large);
padding: 0 var(--space-medium) 0 var(--space-small);
}
.modal-footer {
gap: var(--space-smaller);
}
}
</style>
@@ -0,0 +1,107 @@
<template>
<div class="column">
<woot-modal-header :header-title="headerTitle" />
<form class="row modal-content" @submit.prevent="applyText">
<div v-if="draftMessage" class="w-full">
<h4 class="sub-block-title margin-top-1 ">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.DRAFT_TITLE') }}
</h4>
<p v-dompurify-html="formatMessage(draftMessage, false)" />
<h4 class="sub-block-title margin-top-1">
{{
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.GENERATED_TITLE')
}}
</h4>
</div>
<div>
<AILoader v-if="isGenerating" />
<p v-else v-dompurify-html="formatMessage(generatedContent, false)" />
</div>
<div class="modal-footer justify-content-end w-full">
<woot-button variant="clear" @click.prevent="onClose">
{{
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.CANCEL')
}}
</woot-button>
<woot-button :disabled="!generatedContent">
{{
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.APPLY')
}}
</woot-button>
</div>
</form>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import AILoader from './AILoader.vue';
import aiMixin from 'dashboard/mixins/aiMixin';
export default {
components: {
AILoader,
},
mixins: [aiMixin, messageFormatterMixin],
props: {
aiOption: {
type: String,
required: true,
},
},
data() {
return {
generatedContent: '',
isGenerating: true,
};
},
computed: {
...mapGetters({
appIntegrations: 'integrations/getAppIntegrations',
}),
headerTitle() {
const translationKey = this.aiOption?.toUpperCase();
return translationKey
? this.$t(`INTEGRATION_SETTINGS.OPEN_AI.WITH_AI`, {
option: this.$t(
`INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.${translationKey}`
),
})
: '';
},
},
mounted() {
this.generateAIContent(this.aiOption);
},
methods: {
onClose() {
this.$emit('close');
},
async generateAIContent(type = 'rephrase') {
this.isGenerating = true;
this.generatedContent = await this.processEvent(type);
this.isGenerating = false;
},
applyText() {
this.recordAnalytics(this.aiOption);
this.$emit('apply-text', this.generatedContent);
this.onClose();
},
},
};
</script>
<style lang="scss" scoped>
.modal-content {
padding-top: var(--space-small);
}
.container {
width: 100%;
}
</style>
@@ -0,0 +1,67 @@
<template>
<div class="animation-container margin-top-1">
<div class="ai-typing--wrap ">
<fluent-icon icon="wand" size="14" class="ai-typing--icon" />
<label>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.AI_WRITING') }}
</label>
</div>
<span class="loader" />
<span class="loader" />
<span class="loader" />
</div>
</template>
<script></script>
<style lang="scss" scoped>
.animation-container {
position: relative;
display: flex;
.ai-typing--wrap {
display: flex;
align-items: center;
gap: 4px;
.ai-typing--icon {
color: var(--v-500);
}
}
label {
display: inline-block;
margin-right: var(--space-smaller);
color: var(--v-400);
}
.loader {
display: inline-block;
width: 6px;
height: 6px;
margin-right: var(--space-smaller);
margin-top: var(--space-slab);
background-color: var(--v-300);
border-radius: 50%;
animation: bubble-scale 1.2s infinite;
}
.loader:nth-child(2) {
animation-delay: 0.4s;
}
.loader:nth-child(3) {
animation-delay: 0.8s;
}
@keyframes bubble-scale {
0%,
100% {
transform: scale(1);
}
25% {
transform: scale(1.3);
}
50% {
transform: scale(1);
}
}
}
</style>
@@ -1,6 +1,6 @@
<template>
<div class="avatar-container" :style="style" aria-hidden="true">
{{ userInitial }}
<slot>{{ userInitial }}</slot>
</div>
</template>
@@ -21,6 +21,9 @@ export default {
},
},
computed: {
hasFbConfigured() {
return window.chatwootConfig?.fbAppId;
},
isActive() {
const { key } = this.channel;
if (Object.keys(this.enabledFeatures).length === 0) {
@@ -30,10 +33,7 @@ export default {
return this.enabledFeatures.channel_website;
}
if (key === 'facebook') {
return this.enabledFeatures.channel_facebook;
}
if (key === 'twitter') {
return this.enabledFeatures.channel_twitter;
return this.enabledFeatures.channel_facebook && this.hasFbConfigured;
}
if (key === 'email') {
return this.enabledFeatures.channel_email;
@@ -5,19 +5,21 @@
:title="title"
>
<!-- Using v-show instead of v-if to avoid flickering as v-if removes dom elements. -->
<img
v-show="shouldShowImage"
:src="src"
:class="thumbnailClass"
@load="onImgLoad"
@error="onImgError"
/>
<Avatar
v-show="!shouldShowImage"
:username="userNameWithoutEmoji"
:class="thumbnailClass"
:size="avatarSize"
/>
<slot>
<img
v-show="shouldShowImage"
:src="src"
:class="thumbnailClass"
@load="onImgLoad"
@error="onImgError"
/>
<Avatar
v-show="!shouldShowImage"
:username="userNameWithoutEmoji"
:class="thumbnailClass"
:size="avatarSize"
/>
</slot>
<img
v-if="badgeSrc"
class="source-badge"
@@ -310,11 +310,9 @@ export default {
userFullName: mentionItem.name,
});
const tr = this.editorView.state.tr.replaceWith(
this.range.from,
this.range.to,
node
);
const tr = this.editorView.state.tr
.replaceWith(this.range.from, this.range.to, node)
.insertText(` `);
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
@@ -64,6 +64,12 @@
:has-instagram-story="hasInstagramStory"
:is-web-widget-inbox="isAWebWidgetInbox"
/>
<conversation-label-suggestion
v-if="shouldShowLabelSuggestions"
:suggested-labels="labelSuggestions"
:chat-labels="currentChat.labels"
:conversation-id="currentChat.id"
/>
</ul>
<div
class="conversation-footer"
@@ -91,29 +97,48 @@
</template>
<script>
import { mapGetters } from 'vuex';
// components
import ReplyBox from './ReplyBox';
import Message from './Message';
import ConversationLabelSuggestion from './conversation/LabelSuggestion';
import Banner from 'dashboard/components/ui/Banner.vue';
// stores and apis
import { mapGetters } from 'vuex';
// mixins
import conversationMixin, {
filterDuplicateSourceMessages,
} from '../../../mixins/conversations';
import Banner from 'dashboard/components/ui/Banner.vue';
import { getTypingUsersText } from '../../../helper/commons';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import inboxMixin from 'shared/mixins/inboxMixin';
import configMixin from 'shared/mixins/configMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import aiMixin from 'dashboard/mixins/aiMixin';
// utils
import { getTypingUsersText } from '../../../helper/commons';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
export default {
components: {
Message,
ReplyBox,
Banner,
ConversationLabelSuggestion,
},
mixins: [conversationMixin, inboxMixin, eventListenerMixins],
mixins: [
conversationMixin,
inboxMixin,
eventListenerMixins,
configMixin,
aiMixin,
],
props: {
isContactPanelOpen: {
type: Boolean,
@@ -127,7 +152,11 @@ export default {
heightBeforeLoad: null,
conversationPanel: null,
selectedTweetId: null,
hasUserScrolled: false,
isProgrammaticScroll: false,
isPopoutReplyBox: false,
messageSentSinceOpened: false,
labelSuggestions: [],
};
},
@@ -138,7 +167,20 @@ export default {
inboxesList: 'inboxes/getInboxes',
listLoadingStatus: 'getAllMessagesLoaded',
loadingChatList: 'getChatListLoadingStatus',
appIntegrations: 'integrations/getAppIntegrations',
currentAccountId: 'getCurrentAccountId',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
},
shouldShowLabelSuggestions() {
return (
this.isOpen &&
this.isEnterprise &&
this.isAIIntegrationEnabled &&
!this.messageSentSinceOpened
);
},
inboxId() {
return this.currentChat.inbox_id;
},
@@ -280,18 +322,28 @@ export default {
return;
}
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.messageSentSinceOpened = false;
this.selectedTweetId = null;
},
},
created() {
bus.$on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a new message comes in, we refetch the label suggestions
bus.$on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
bus.$on(BUS_EVENTS.SET_TWEET_REPLY, this.setSelectedTweet);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
bus.$on(BUS_EVENTS.MESSAGE_SENT, () => {
this.messageSentSinceOpened = true;
});
},
mounted() {
this.addScrollListener();
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
},
beforeDestroy() {
@@ -300,6 +352,49 @@ export default {
},
methods: {
async fetchSuggestions() {
// start empty, this ensures that the label suggestions are not shown
this.labelSuggestions = [];
if (this.isLabelSuggestionDismissed()) {
return;
}
if (!this.isEnterprise) {
return;
}
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
if (!this.isAIIntegrationEnabled) {
return;
}
this.labelSuggestions = await this.fetchLabelSuggestions({
conversationId: this.currentChat.id,
});
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
// so we use the nextTick method
this.$nextTick(() => {
// this param is added to route, telling the UI to navigate to the message
// it is triggered by the SCROLL_TO_MESSAGE method
// see setActiveChat on ConversationView.vue for more info
const { messageId } = this.$route.query;
// only trigger the scroll to bottom if the user has not scrolled
// and there's no active messageId that is selected in view
if (!messageId && !this.hasUserScrolled) {
this.scrollToBottom();
}
});
},
isLabelSuggestionDismissed() {
const dismissed = this.getDismissedConversations(this.currentAccountId);
return dismissed[this.currentAccountId].includes(this.conversationId);
},
fetchAllAttachmentsFromCurrentChat() {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
@@ -314,6 +409,7 @@ export default {
this.$nextTick(() => {
const messageElement = document.getElementById('message' + messageId);
if (messageElement) {
this.isProgrammaticScroll = true;
messageElement.scrollIntoView({ behavior: 'smooth' });
this.fetchPreviousMessages();
} else {
@@ -344,18 +440,34 @@ export default {
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
},
scrollToBottom() {
this.isProgrammaticScroll = true;
let relevantMessages = [];
// label suggestions are not part of the messages list
// so we need to handle them separately
let labelSuggestions = this.conversationPanel.querySelector(
'.label-suggestion'
);
// if there are unread messages, scroll to the first unread message
if (this.unreadMessageCount > 0) {
// capturing only the unread messages
relevantMessages = this.conversationPanel.querySelectorAll(
'.message--unread'
);
} else if (labelSuggestions) {
// when scrolling to the bottom, the label suggestions is below the last message
// so we scroll there if there are no unread messages
// Unread messages always take the highest priority
relevantMessages = [labelSuggestions];
} else {
// if there are no unread messages or label suggestion, scroll to the last message
// capturing last message from the messages list
relevantMessages = Array.from(
this.conversationPanel.querySelectorAll('.message--read')
).slice(-1);
}
this.conversationPanel.scrollTop = calculateScrollTop(
this.conversationPanel.scrollHeight,
this.$el.scrollHeight,
@@ -402,6 +514,13 @@ export default {
},
handleScroll(e) {
if (this.isProgrammaticScroll) {
// Reset the flag
this.isProgrammaticScroll = false;
this.hasUserScrolled = false;
} else {
this.hasUserScrolled = true;
}
bus.$emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.fetchPreviousMessages(e.target.scrollTop);
},
@@ -178,8 +178,6 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { DirectUpload } from 'activestorage';
import { frontendURL } from '../../../helper/URLHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
@@ -583,30 +581,15 @@ export default {
display_rich_content_editor: !this.showRichContentEditor,
});
},
getSavedDraftMessages() {
return LocalStorage.get(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES) || {};
},
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
const savedDraftMessages = this.getSavedDraftMessages();
const key = `draft-${conversationId}-${replyType}`;
const draftToSave = trimContent(this.message || '');
const {
[key]: currentDraft,
...restOfDraftMessages
} = savedDraftMessages;
const updatedDraftMessages = draftToSave
? {
...restOfDraftMessages,
[key]: draftToSave,
}
: restOfDraftMessages;
LocalStorage.set(
LOCAL_STORAGE_KEYS.DRAFT_MESSAGES,
updatedDraftMessages
);
this.$store.dispatch('draftMessages/set', {
key,
message: draftToSave,
});
}
},
setToDraft(conversationId, replyType) {
@@ -615,24 +598,14 @@ export default {
},
getFromDraft() {
if (this.conversationIdByRoute) {
try {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
const savedDraftMessages = this.getSavedDraftMessages();
this.message = `${savedDraftMessages[key] || ''}`;
} catch (error) {
this.message = '';
}
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
this.message = this.$store.getters['draftMessages/get'](key) || '';
}
},
removeFromDraft() {
if (this.conversationIdByRoute) {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
const draftMessages = this.getSavedDraftMessages();
const { [key]: toBeRemoved, ...updatedDraftMessages } = draftMessages;
LocalStorage.set(
LOCAL_STORAGE_KEYS.DRAFT_MESSAGES,
updatedDraftMessages
);
this.$store.dispatch('draftMessages/delete', { key });
}
},
handleKeyEvents(e) {
@@ -782,6 +755,7 @@ export default {
messagePayload
);
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
bus.$emit(BUS_EVENTS.MESSAGE_SENT);
this.removeFromDraft();
} catch (error) {
const errorMessage =
@@ -808,6 +782,9 @@ export default {
},
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
const { can_reply: canReply } = this.currentChat;
this.$store.dispatch('draftMessages/setReplyEditorMode', {
mode,
});
if (canReply || this.isAWhatsAppChannel) this.replyType = mode;
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
@@ -1076,7 +1053,7 @@ export default {
// Retrieve the email of the current conversation's sender
const conversationContact = this.currentChat?.meta?.sender?.email || '';
let cc = [...emailAttributes.cc] || [];
let cc = emailAttributes.cc ? [...emailAttributes.cc] : [];
let to = [];
// there might be a situation where the current conversation will include a message from a third person,
@@ -0,0 +1,275 @@
<template>
<li
v-if="shouldShowSuggestions"
class="label-suggestion right"
@mouseover="isHovered = true"
@mouseleave="isHovered = false"
>
<div class="wrap">
<div class="label-suggestion--container">
<h6 class="label-suggestion--title">Suggested labels</h6>
<div v-if="!fetchingSuggestions" class="label-suggestion--options">
<button
v-for="label in preparedLabels"
:key="label.title"
v-tooltip.top="{
content: selectedLabels.includes(label.title)
? $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DESELECT')
: labelTooltip,
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
class="label-suggestion--option"
@click="pushOrAddLabel(label.title)"
>
<woot-label
variant="dashed"
v-bind="label"
:bg-color="
selectedLabels.includes(label.title) ? 'var(--w-100)' : ''
"
/>
</button>
<woot-button
v-if="preparedLabels.length === 1"
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
variant="smooth"
:color-scheme="isHovered ? 'alert' : 'primary'"
class="label--add"
icon="dismiss"
size="tiny"
@click="dismissSuggestions"
/>
</div>
<div v-if="preparedLabels.length > 1">
<woot-button
:variant="selectedLabels.length === 0 ? 'smooth' : ''"
class="label--add"
icon="add"
size="tiny"
@click="addAllLabels"
>
{{ addButtonText }}
</woot-button>
<woot-button
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
:color-scheme="isHovered ? 'alert' : 'primary'"
variant="smooth"
class="label--add"
icon="dismiss"
size="tiny"
@click="dismissSuggestions"
/>
</div>
</div>
<div class="sender--info has-tooltip" data-original-title="null">
<woot-thumbnail
v-tooltip.top="{
content: $t('LABEL_MGMT.SUGGESTIONS.POWERED_BY'),
delay: { show: 600, hide: 0 },
hideOnClick: true,
}"
size="16px"
>
<avatar class="user-thumbnail thumbnail-rounded">
<fluent-icon class="chatwoot-ai-icon" icon="chatwoot-ai" />
</avatar>
</woot-thumbnail>
</div>
</div>
</li>
</template>
<script>
// components
import WootButton from '../../../ui/WootButton.vue';
import Avatar from '../../Avatar.vue';
import aiMixin from 'dashboard/mixins/aiMixin';
// store & api
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';
export default {
name: 'LabelSuggestion',
components: {
Avatar,
WootButton,
},
mixins: [aiMixin],
props: {
suggestedLabels: {
type: Array,
required: true,
},
chatLabels: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
isDismissed: false,
fetchingSuggestions: false,
isHovered: false,
selectedLabels: [],
};
},
computed: {
...mapGetters({
allLabels: 'labels/getLabels',
currentAccountId: 'getCurrentAccountId',
}),
labelTooltip() {
if (this.preparedLabels.length > 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
}
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.SINGLE_SUGGESTION');
},
addButtonText() {
if (this.selectedLabels.length === 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_SELECTED_LABEL');
}
if (this.selectedLabels.length > 1) {
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_SELECTED_LABELS');
}
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_ALL_LABELS');
},
preparedLabels() {
return this.allLabels.filter(label =>
this.suggestedLabels.includes(label.title)
);
},
shouldShowSuggestions() {
if (this.isDismissed) return false;
if (!this.isAIIntegrationEnabled) return false;
return (
!this.fetchingSuggestions &&
this.preparedLabels.length &&
this.chatLabels.length === 0
);
},
},
watch: {
conversationId: {
immediate: true,
handler() {
this.selectedLabels = [];
this.isDismissed = this.isConversationDismissed();
},
},
},
methods: {
pushOrAddLabel(label) {
if (this.preparedLabels.length === 1) {
this.addAllLabels();
return;
}
if (!this.selectedLabels.includes(label)) {
this.selectedLabels.push(label);
} else {
this.selectedLabels = this.selectedLabels.filter(l => l !== label);
}
},
dismissSuggestions() {
const dismissed = this.getDismissedConversations(this.currentAccountId);
dismissed[this.currentAccountId].push(this.conversationId);
LocalStorage.set(
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
dismissed
);
// dismiss this once the values are set
this.isDismissed = true;
this.trackLabelEvent(OPEN_AI_EVENTS.DISMISS_LABEL_SUGGESTION);
},
isConversationDismissed() {
const dismissed = this.getDismissedConversations(this.currentAccountId);
return dismissed[this.currentAccountId].includes(this.conversationId);
},
addAllLabels() {
let labelsToAdd = this.selectedLabels;
if (!labelsToAdd.length) {
labelsToAdd = this.preparedLabels.map(label => label.title);
}
this.$store.dispatch('conversationLabels/update', {
conversationId: this.conversationId,
labels: labelsToAdd,
});
this.trackLabelEvent(OPEN_AI_EVENTS.LABEL_SUGGESTION_APPLIED);
},
trackLabelEvent(event) {
const payload = {
conversationId: this.conversationId,
account: this.currentAccountId,
suggestions: this.suggestedLabels,
labelsApplied: this.selectedLabels.length
? this.selectedLabels
: this.suggestedLabels,
};
this.$track(event, payload);
},
},
};
</script>
<style scoped lang="scss">
.wrap {
display: flex;
}
.label-suggestion {
flex-direction: row;
justify-content: flex-end;
margin-top: var(--space-normal);
.label-suggestion--container {
max-width: 300px;
}
.label-suggestion--options {
text-align: right;
display: flex;
align-items: center;
gap: var(--space-micro);
button.label-suggestion--option {
.label {
cursor: pointer;
margin-bottom: 0;
}
}
}
.chatwoot-ai-icon {
height: var(--font-size-mini);
width: var(--font-size-mini);
}
.label-suggestion--title {
color: var(--b-600);
margin-top: var(--space-micro);
font-size: var(--font-size-micro);
}
}
</style>
@@ -3,4 +3,5 @@ export const LOCAL_STORAGE_KEYS = {
WIDGET_BUILDER: 'widgetBubble_',
DRAFT_MESSAGES: 'draftMessages',
COLOR_SCHEME: 'color_scheme',
DISMISSED_LABEL_SUGGESTIONS: 'dismissedLabelSuggestions',
};
@@ -81,4 +81,12 @@ export const OPEN_AI_EVENTS = Object.freeze({
SUMMARIZE: 'OpenAI: Used summarize',
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
REPHRASE: 'OpenAI: Used rephrase',
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',
});
@@ -235,7 +235,7 @@
},
"INBOXES": {
"TITLE": "Connect Inboxes",
"DESCRIPTION": "Connect various channels through which your customers would be talking to you. It can be a website live-chat, your Facebook or Twitter page or even your WhatsApp number.",
"DESCRIPTION": "Connect various channels through which your customers would be talking to you. It can be a website live-chat, your Facebook page or even your WhatsApp number.",
"NEW_LINK": "Click here to create an inbox"
},
"LABELS": {
@@ -110,7 +110,8 @@
"SNOOZE_CONVERSATION": "Snooze Conversation",
"ADD_LABEL": "Add label to the conversation",
"REMOVE_LABEL": "Remove label from the conversation",
"SETTINGS": "Settings"
"SETTINGS": "Settings",
"AI_ASSIST": "AI Assist"
},
"COMMANDS": {
"GO_TO_CONVERSATION_DASHBOARD": "Go to Conversation Dashboard",
@@ -132,6 +133,7 @@
"GO_TO_NOTIFICATIONS": "Go to Notifications",
"ADD_LABELS_TO_CONVERSATION": "Add label to the conversation",
"ASSIGN_AN_AGENT": "Assign an agent",
"AI_ASSIST": "AI Assist",
"ASSIGN_PRIORITY": "Assign priority",
"ASSIGN_A_TEAM": "Assign a team",
"MUTE_CONVERSATION": "Mute conversation",
@@ -111,6 +111,17 @@
"PLACEHOLDER": "Please enter your Twilio Account SID",
"ERROR": "This field is required"
},
"API_KEY": {
"USE_API_KEY": "Use API Key Authentication",
"LABEL": "API Key SID",
"PLACEHOLDER": "Please enter your API Key SID",
"ERROR": "This field is required"
},
"API_KEY_SECRET": {
"LABEL": "API Key Secret",
"PLACEHOLDER": "Please enter your API Key Secret",
"ERROR": "This field is required"
},
"MESSAGING_SERVICE_SID": {
"LABEL": "Messaging Service SID",
"PLACEHOLDER": "Please enter your Twilio Messaging Service SID",
@@ -329,7 +340,7 @@
},
"AUTH": {
"TITLE": "Choose a channel",
"DESC": "Chatwoot supports live-chat widgets, Facebook Messenger, Twitter profiles, WhatsApp, Emails, etc., as channels. If you want to build a custom channel, you can create it using the API channel. To get started, choose one of the channels below."
"DESC": "Chatwoot supports live-chat widgets, Facebook Messenger, WhatsApp, Emails, etc., as channels. If you want to build a custom channel, you can create it using the API channel. To get started, choose one of the channels below."
},
"AGENTS": {
"TITLE": "Agents",
@@ -83,6 +83,28 @@
"CREATE_ERROR": "There was an error creating a meeting link, please try again"
},
"OPEN_AI": {
"AI_ASSIST": "AI Assist",
"WITH_AI": " %{option} with AI ",
"OPTIONS": {
"REPLY_SUGGESTION": "Reply Suggestion",
"SUMMARIZE": "Summarize",
"REPHRASE": "Improve Writing",
"FIX_SPELLING_GRAMMAR": "Fix Spelling and Grammar",
"SHORTEN": "Shorten",
"EXPAND": "Expand",
"MAKE_FRIENDLY": "Change message tone to friendly",
"MAKE_FORMAL": "Use formal tone",
"SIMPLIFY": "Simplify"
},
"ASSISTANCE_MODAL": {
"DRAFT_TITLE": "Draft content",
"GENERATED_TITLE": "Generated content",
"AI_WRITING": "AI is writing",
"BUTTONS": {
"APPLY": "Use this suggestion",
"CANCEL": "Cancel"
}
},
"TITLE": "Improve With AI",
"SUMMARY_TITLE": "Summary with AI",
"REPLY_TITLE": "Reply suggestion with AI",
@@ -34,6 +34,19 @@
"DELETE": "Delete",
"CANCEL": "Cancel"
},
"SUGGESTIONS": {
"TOOLTIP": {
"SINGLE_SUGGESTION": "Add label to conversation",
"MULTIPLE_SUGGESTION": "Select this label",
"DESELECT": "Deselect label",
"DISMISS": "Dismiss suggestion"
},
"POWERED_BY": "Chatwoot AI",
"DISMISS": "Dismiss",
"ADD_SELECTED_LABELS": "Add selected labels",
"ADD_SELECTED_LABEL": "Add selected label",
"ADD_ALL_LABELS": "Add all labels"
},
"ADD": {
"TITLE": "Add label",
"DESC": "Labels let you group the conversations together.",
@@ -3,7 +3,8 @@
"TITLE": "Login to Chatwoot",
"EMAIL": {
"LABEL": "Email",
"PLACEHOLDER": "example@companyname.com"
"PLACEHOLDER": "example@companyname.com",
"ERROR": "Please enter a valid email address"
},
"PASSWORD": {
"LABEL": "Password",
@@ -20,7 +21,7 @@
"NO_ACCOUNT_FOUND": "We couldn't find an account for your email address."
},
"FORGOT_PASSWORD": "Forgot your password?",
"CREATE_NEW_ACCOUNT": "Create new account",
"CREATE_NEW_ACCOUNT": "Create a new account",
"SUBMIT": "Login"
}
}
+118
View File
@@ -0,0 +1,118 @@
import { mapGetters } from 'vuex';
import { OPEN_AI_EVENTS } from '../helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from '../constants/localStorage';
import { LocalStorage } from '../../shared/helpers/localStorage';
import OpenAPI from '../api/integrations/openapi';
export default {
mounted() {
this.fetchIntegrationsIfRequired();
},
computed: {
...mapGetters({
appIntegrations: 'integrations/getAppIntegrations',
currentChat: 'getSelectedChat',
replyMode: 'draftMessages/getReplyEditorMode',
}),
isAIIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
);
},
hookId() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
).hooks[0].id;
},
draftMessage() {
return this.$store.getters['draftMessages/get'](this.draftKey);
},
draftKey() {
return `draft-${this.conversationId}-${this.replyMode}`;
},
conversationId() {
return this.currentChat?.id;
},
},
methods: {
async fetchIntegrationsIfRequired() {
if (!this.appIntegrations.length) {
await this.$store.dispatch('integrations/get');
}
},
async recordAnalytics(type, payload) {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
this.$track(event, {
type,
...payload,
});
}
},
async fetchLabelSuggestions({ conversationId }) {
try {
const result = await OpenAPI.processEvent({
type: 'label_suggestion',
hookId: this.hookId,
conversationId: conversationId,
});
const {
data: { message: labels },
} = result;
return this.cleanLabels(labels);
} catch (error) {
return [];
}
},
getDismissedConversations(accountId) {
const suggestionKey = LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS;
// fetch the value from Storage
const valueFromStorage = LocalStorage.get(suggestionKey);
// Case 1: the key is not initialized
if (!valueFromStorage) {
LocalStorage.set(suggestionKey, {
[accountId]: [],
});
return LocalStorage.get(suggestionKey);
}
// Case 2: the key is initialized, but account ID is not present
if (!valueFromStorage[accountId]) {
valueFromStorage[accountId] = [];
LocalStorage.set(suggestionKey, valueFromStorage);
return LocalStorage.get(suggestionKey);
}
return valueFromStorage;
},
cleanLabels(labels) {
return labels
.toLowerCase() // Set it to lowercase
.split(',') // split the string into an array
.filter(label => label.trim()) // remove any empty strings
.map(label => label.trim()) // trim the words
.filter((label, index, self) => self.indexOf(label) === index); // remove any duplicates
},
async processEvent(type = 'rephrase') {
try {
const result = await OpenAPI.processEvent({
hookId: this.hookId,
type,
content: this.draftMessage,
conversationId: this.conversationId,
});
const {
data: { message: generatedMessage },
} = result;
return generatedMessage;
} catch (error) {
this.showAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'));
return '';
}
},
},
};
@@ -0,0 +1,136 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import aiMixin from '../aiMixin';
import Vuex from 'vuex';
import OpenAPI from '../../api/integrations/openapi';
import { LocalStorage } from '../../../shared/helpers/localStorage';
jest.mock('../../api/integrations/openapi');
jest.mock('../../../shared/helpers/localStorage');
const localVue = createLocalVue();
localVue.use(Vuex);
describe('aiMixin', () => {
let wrapper;
let getters;
let emptyGetters;
let component;
let actions;
beforeEach(() => {
OpenAPI.processEvent = jest.fn();
LocalStorage.set = jest.fn();
LocalStorage.get = jest.fn();
actions = {
['integrations/get']: jest.fn(),
};
getters = {
['integrations/getAppIntegrations']: () => [
{
id: 'openai',
hooks: [{ id: 'hook1' }],
},
],
};
component = {
render() {},
title: 'TestComponent',
mixins: [aiMixin],
};
wrapper = shallowMount(component, {
store: new Vuex.Store({
getters: getters,
actions,
}),
localVue,
});
emptyGetters = {
['integrations/getAppIntegrations']: () => [],
};
});
it('fetches integrations if required', async () => {
wrapper = shallowMount(component, {
store: new Vuex.Store({
getters: emptyGetters,
actions,
}),
localVue,
});
const dispatchSpy = jest.spyOn(wrapper.vm.$store, 'dispatch');
await wrapper.vm.fetchIntegrationsIfRequired();
expect(dispatchSpy).toHaveBeenCalledWith('integrations/get');
});
it('does not fetch integrations', async () => {
const dispatchSpy = jest.spyOn(wrapper.vm.$store, 'dispatch');
await wrapper.vm.fetchIntegrationsIfRequired();
expect(dispatchSpy).not.toHaveBeenCalledWith('integrations/get');
expect(wrapper.vm.isAIIntegrationEnabled).toBeTruthy();
});
it('fetches label suggestions', async () => {
const processEventSpy = jest.spyOn(OpenAPI, 'processEvent');
await wrapper.vm.fetchLabelSuggestions({
conversationId: '123',
});
expect(processEventSpy).toHaveBeenCalledWith({
type: 'label_suggestion',
hookId: 'hook1',
conversationId: '123',
});
});
it('gets dismissed conversations', () => {
const getSpy = jest.spyOn(LocalStorage, 'get');
const setSpy = jest.spyOn(LocalStorage, 'set');
const accountId = 123;
const valueFromStorage = { [accountId]: ['conv1', 'conv2'] };
// in case key is not initialized
getSpy.mockReturnValueOnce(null);
wrapper.vm.getDismissedConversations(accountId);
expect(getSpy).toHaveBeenCalledWith('dismissedLabelSuggestions');
expect(setSpy).toHaveBeenCalledWith('dismissedLabelSuggestions', {
[accountId]: [],
});
// rest spy
getSpy.mockReset();
setSpy.mockReset();
// in case we get the value from storage
getSpy.mockReturnValueOnce(valueFromStorage);
const result = wrapper.vm.getDismissedConversations(accountId);
expect(result).toEqual(valueFromStorage);
expect(getSpy).toHaveBeenCalledWith('dismissedLabelSuggestions');
expect(setSpy).not.toHaveBeenCalled();
// rest spy
getSpy.mockReset();
setSpy.mockReset();
// in case we get the value from storage but accountId is not present
getSpy.mockReturnValueOnce(valueFromStorage);
wrapper.vm.getDismissedConversations(234);
expect(getSpy).toHaveBeenCalledWith('dismissedLabelSuggestions');
expect(setSpy).toHaveBeenCalledWith('dismissedLabelSuggestions', {
...valueFromStorage,
234: [],
});
});
it('cleans labels', () => {
const labels = 'label1, label2, label1';
expect(wrapper.vm.cleanLabels(labels)).toEqual(['label1', 'label2']);
});
});
@@ -58,3 +58,11 @@ export const ICON_PRIORITY_NONE = `<svg role="img" class="ninja-icon ninja-icon-
<rect width="24" height="24" fill="#F1F5F8"/>
<path d="M13.5686 8L11.1579 16.9562H10L12.4107 8H13.5686Z" fill="#446888"/>
</svg>`;
export const ICON_AI_ASSIST = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m13.314 7.565l-.136.126l-10.48 10.488a2.27 2.27 0 0 0 3.211 3.208L16.388 10.9a2.251 2.251 0 0 0-.001-3.182l-.157-.146a2.25 2.25 0 0 0-2.916-.007Zm-.848 2.961l1.088 1.088l-8.706 8.713a.77.77 0 1 1-1.089-1.088l8.707-8.713Zm4.386 4.48L16.75 15a.75.75 0 0 0-.743.648L16 15.75v.75h-.75a.75.75 0 0 0-.743.648l-.007.102c0 .38.282.694.648.743l.102.007H16v.75c0 .38.282.694.648.743l.102.007a.75.75 0 0 0 .743-.648l.007-.102V18h.75a.75.75 0 0 0 .743-.648L19 17.25a.75.75 0 0 0-.648-.743l-.102-.007h-.75v-.75a.75.75 0 0 0-.648-.743L16.75 15l.102.007Zm-1.553-6.254l.027.027a.751.751 0 0 1 0 1.061l-.711.713l-1.089-1.089l.73-.73a.75.75 0 0 1 1.043.018ZM6.852 5.007L6.75 5a.75.75 0 0 0-.743.648L6 5.75v.75h-.75a.75.75 0 0 0-.743.648L4.5 7.25c0 .38.282.693.648.743L5.25 8H6v.75c0 .38.282.693.648.743l.102.007a.75.75 0 0 0 .743-.648L7.5 8.75V8h.75a.75.75 0 0 0 .743-.648L9 7.25a.75.75 0 0 0-.648-.743L8.25 6.5H7.5v-.75a.75.75 0 0 0-.648-.743L6.75 5l.102.007Zm12-2L18.75 3a.75.75 0 0 0-.743.648L18 3.75v.75h-.75a.75.75 0 0 0-.743.648l-.007.102c0 .38.282.693.648.743L17.25 6H18v.75c0 .38.282.693.648.743l.102.007a.75.75 0 0 0 .743-.648l.007-.102V6h.75a.75.75 0 0 0 .743-.648L21 5.25a.75.75 0 0 0-.648-.743L20.25 4.5h-.75v-.75a.75.75 0 0 0-.648-.743L18.75 3l.102.007Z" fill="currentColor"/></svg>`;
export const ICON_AI_SUMMARY = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H18a2.5 2.5 0 0 1 2.5 2.5v14.25a.75.75 0 0 1-.75.75H5.5a1 1 0 0 0 1 1h13.25a.75.75 0 0 1 0 1.5H6.5A2.5 2.5 0 0 1 4 19.5v-15ZM5.5 18H19V4.5a1 1 0 0 0-1-1H6.5a1 1 0 0 0-1 1V18Z" fill="currentColor"/></svg>`;
export const ICON_AI_SPELLING = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4.53 12.97a.75.75 0 0 0-1.06 1.06l4.5 4.5a.75.75 0 0 0 1.06 0l11-11a.75.75 0 0 0-1.06-1.06L8.5 16.94l-3.97-3.97Z" fill="currentColor"/></svg>`;
export const ICON_AI_EXPAND = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.75 19.5h14.5a.75.75 0 0 0 .102-1.493L21.25 18H6.75a.75.75 0 0 0-.102 1.493l.102.007Zm0-15h14.5a.75.75 0 0 0 .102-1.493L21.25 3H6.75a.75.75 0 0 0-.102 1.493l.102.007Zm7 3.5a.75.75 0 0 0 0 1.5h7.5a.75.75 0 0 0 0-1.5h-7.5ZM13 13.75a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5a.75.75 0 0 1-.75-.75Zm-2-2.25a4.5 4.5 0 1 1-9 0a4.5 4.5 0 0 1 9 0Zm-4-2a.5.5 0 0 0-1 0V11H4.5a.5.5 0 0 0 0 1H6v1.5a.5.5 0 0 0 1 0V12h1.5a.5.5 0 0 0 0-1H7V9.5Z" fill="currentColor"/></svg>`;
export const ICON_AI_SHORTEN = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.75 4.5h14.5a.75.75 0 0 0 .102-1.493L21.25 3H6.75a.75.75 0 0 0-.102 1.493l.102.007Zm0 15h14.5a.75.75 0 0 0 .102-1.493L21.25 18H6.75a.75.75 0 0 0-.102 1.493l.102.007Zm7-11.5a.75.75 0 0 0 0 1.5h7.5a.75.75 0 0 0 0-1.5h-7.5ZM13 13.75a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5a.75.75 0 0 1-.75-.75Zm-2-2.25a4.5 4.5 0 1 1-9 0a4.5 4.5 0 0 1 9 0Zm-2 0a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 0 0 1h4a.5.5 0 0 0 .5-.5Z" fill="currentColor"/></svg>`;
export const ICON_AI_GRAMMAR = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3 17h7.522l-2 2H3a1 1 0 0 1-.117-1.993L3 17Zm0-2h7.848a1.75 1.75 0 0 1-.775-2H3l-.117.007A1 1 0 0 0 3 15Zm0-8h18l.117-.007A1 1 0 0 0 21 5H3l-.117.007A1 1 0 0 0 3 7Zm9.72 9.216a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 1 1-1.06-1.06l4.5-4.5ZM3 9h10a1 1 0 0 1 .117 1.993L13 11H3a1 1 0 0 1-.117-1.993L3 9Zm13.5-1a.75.75 0 0 1 .744.658l.14 1.13a3.25 3.25 0 0 0 2.828 2.829l1.13.139a.75.75 0 0 1 0 1.488l-1.13.14a3.25 3.25 0 0 0-2.829 2.828l-.139 1.13a.75.75 0 0 1-1.488 0l-.14-1.13a3.25 3.25 0 0 0-2.828-2.829l-1.13-.139a.75.75 0 0 1 0-1.488l1.13-.14a3.25 3.25 0 0 0 2.829-2.828l.139-1.13A.75.75 0 0 1 16.5 8Z" fill="currentColor"/></svg>`;
@@ -0,0 +1,129 @@
import wootConstants from 'dashboard/constants/globals';
import {
CMD_MUTE_CONVERSATION,
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
CMD_SEND_TRANSCRIPT,
CMD_SNOOZE_CONVERSATION,
CMD_UNMUTE_CONVERSATION,
} from './commandBarBusEvents';
import {
ICON_MUTE_CONVERSATION,
ICON_REOPEN_CONVERSATION,
ICON_RESOLVE_CONVERSATION,
ICON_SEND_TRANSCRIPT,
ICON_SNOOZE_CONVERSATION,
ICON_UNMUTE_CONVERSATION,
} from './CommandBarIcons';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
export const OPEN_CONVERSATION_ACTIONS = [
{
id: 'resolve_conversation',
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_RESOLVE_CONVERSATION,
handler: () => bus.$emit(CMD_RESOLVE_CONVERSATION),
},
];
export const SNOOZE_CONVERSATION_ACTIONS = [
{
id: 'snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_REPLY,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_REPLY',
parent: 'snooze_conversation',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_REPLY),
},
{
id: SNOOZE_OPTIONS.AN_HOUR_FROM_NOW,
title: 'COMMAND_BAR.COMMANDS.AN_HOUR_FROM_NOW',
parent: 'snooze_conversation',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
title: 'COMMAND_BAR.COMMANDS.UNTIL_TOMORROW',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_WEEK',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_MONTH',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
title: 'COMMAND_BAR.COMMANDS.CUSTOM',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
},
];
export const RESOLVED_CONVERSATION_ACTIONS = [
{
id: 'reopen_conversation',
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_REOPEN_CONVERSATION,
handler: () => bus.$emit(CMD_REOPEN_CONVERSATION),
},
];
export const SEND_TRANSCRIPT_ACTION = {
id: 'send_transcript',
title: 'COMMAND_BAR.COMMANDS.SEND_TRANSCRIPT',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_SEND_TRANSCRIPT,
handler: () => bus.$emit(CMD_SEND_TRANSCRIPT),
};
export const UNMUTE_ACTION = {
id: 'unmute_conversation',
title: 'COMMAND_BAR.COMMANDS.UNMUTE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_UNMUTE_CONVERSATION,
handler: () => bus.$emit(CMD_UNMUTE_CONVERSATION),
};
export const MUTE_ACTION = {
id: 'mute_conversation',
title: 'COMMAND_BAR.COMMANDS.MUTE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_MUTE_CONVERSATION,
handler: () => bus.$emit(CMD_MUTE_CONVERSATION),
};
@@ -14,3 +14,4 @@ export const CMD_TOGGLE_CONTACT_SIDEBAR = 'CMD_TOGGLE_CONTACT_SIDEBAR';
export const CMD_REOPEN_CONVERSATION = 'CMD_REOPEN_CONVERSATION';
export const CMD_RESOLVE_CONVERSATION = 'CMD_RESOLVE_CONVERSATION';
export const CMD_SNOOZE_CONVERSATION = 'CMD_SNOOZE_CONVERSATION';
export const CMD_AI_ASSIST = 'CMD_AI_ASSIST';
@@ -1,142 +1,34 @@
import { mapGetters } from 'vuex';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_MUTE_CONVERSATION,
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
CMD_SEND_TRANSCRIPT,
CMD_SNOOZE_CONVERSATION,
CMD_UNMUTE_CONVERSATION,
} from './commandBarBusEvents';
import { CMD_AI_ASSIST } from './commandBarBusEvents';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import {
ICON_ADD_LABEL,
ICON_ASSIGN_AGENT,
ICON_ASSIGN_PRIORITY,
ICON_ASSIGN_TEAM,
ICON_MUTE_CONVERSATION,
ICON_REMOVE_LABEL,
ICON_REOPEN_CONVERSATION,
ICON_RESOLVE_CONVERSATION,
ICON_SEND_TRANSCRIPT,
ICON_SNOOZE_CONVERSATION,
ICON_UNMUTE_CONVERSATION,
ICON_PRIORITY_URGENT,
ICON_PRIORITY_HIGH,
ICON_PRIORITY_LOW,
ICON_PRIORITY_MEDIUM,
ICON_PRIORITY_NONE,
ICON_AI_ASSIST,
ICON_AI_SUMMARY,
ICON_AI_SHORTEN,
ICON_AI_EXPAND,
ICON_AI_GRAMMAR,
} from './CommandBarIcons';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
const OPEN_CONVERSATION_ACTIONS = [
{
id: 'resolve_conversation',
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_RESOLVE_CONVERSATION,
handler: () => bus.$emit(CMD_RESOLVE_CONVERSATION),
},
];
const SNOOZE_CONVERSATION_ACTIONS = [
{
id: 'snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_REPLY,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_REPLY',
parent: 'snooze_conversation',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_REPLY),
},
{
id: SNOOZE_OPTIONS.AN_HOUR_FROM_NOW,
title: 'COMMAND_BAR.COMMANDS.AN_HOUR_FROM_NOW',
parent: 'snooze_conversation',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
title: 'COMMAND_BAR.COMMANDS.UNTIL_TOMORROW',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_WEEK',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_MONTH',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
title: 'COMMAND_BAR.COMMANDS.CUSTOM',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
},
];
const RESOLVED_CONVERSATION_ACTIONS = [
{
id: 'reopen_conversation',
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_REOPEN_CONVERSATION,
handler: () => bus.$emit(CMD_REOPEN_CONVERSATION),
},
];
const SEND_TRANSCRIPT_ACTION = {
id: 'send_transcript',
title: 'COMMAND_BAR.COMMANDS.SEND_TRANSCRIPT',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_SEND_TRANSCRIPT,
handler: () => bus.$emit(CMD_SEND_TRANSCRIPT),
};
const UNMUTE_ACTION = {
id: 'unmute_conversation',
title: 'COMMAND_BAR.COMMANDS.UNMUTE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_UNMUTE_CONVERSATION,
handler: () => bus.$emit(CMD_UNMUTE_CONVERSATION),
};
const MUTE_ACTION = {
id: 'mute_conversation',
title: 'COMMAND_BAR.COMMANDS.MUTE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_MUTE_CONVERSATION,
handler: () => bus.$emit(CMD_MUTE_CONVERSATION),
};
import {
OPEN_CONVERSATION_ACTIONS,
SNOOZE_CONVERSATION_ACTIONS,
RESOLVED_CONVERSATION_ACTIONS,
SEND_TRANSCRIPT_ACTION,
UNMUTE_ACTION,
MUTE_ACTION,
} from './commandBarActions';
export const isAConversationRoute = routeName =>
[
@@ -162,9 +54,24 @@ export default {
activeLabels() {
this.setCommandbarData();
},
draftMessage() {
this.setCommandbarData();
},
replyMode() {
this.setCommandbarData();
},
},
computed: {
...mapGetters({ currentChat: 'getSelectedChat' }),
...mapGetters({
currentChat: 'getSelectedChat',
replyMode: 'draftMessages/getReplyEditorMode',
}),
draftMessage() {
return this.$store.getters['draftMessages/get'](this.draftKey);
},
draftKey() {
return `draft-${this.conversationId}-${this.replyMode}`;
},
inboxId() {
return this.currentChat?.inbox_id;
},
@@ -337,6 +244,94 @@ export default {
]);
},
nonDraftMessageAIAssistActions() {
if (this.replyMode === REPLY_EDITOR_MODES.REPLY) {
return [
{
label: this.$t(
'INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPLY_SUGGESTION'
),
key: 'reply_suggestion',
icon: ICON_AI_ASSIST,
},
];
}
return [
{
label: this.$t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SUMMARIZE'),
key: 'summarize',
icon: ICON_AI_SUMMARY,
},
];
},
draftMessageAIAssistActions() {
return [
{
label: this.$t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
key: 'rephrase',
icon: ICON_AI_ASSIST,
},
{
label: this.$t(
'INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.FIX_SPELLING_GRAMMAR'
),
key: 'fix_spelling_grammar',
icon: ICON_AI_GRAMMAR,
},
{
label: this.$t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
key: 'expand',
icon: ICON_AI_EXPAND,
},
{
label: this.$t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
key: 'shorten',
icon: ICON_AI_SHORTEN,
},
{
label: this.$t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
key: 'make_friendly',
icon: ICON_AI_ASSIST,
},
{
label: this.$t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FORMAL'),
key: 'make_formal',
icon: ICON_AI_ASSIST,
},
{
label: this.$t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
key: 'simplify',
icon: ICON_AI_ASSIST,
},
];
},
AIAssistActions() {
const aiOptions = this.draftMessage
? this.draftMessageAIAssistActions
: this.nonDraftMessageAIAssistActions;
const options = aiOptions.map(item => ({
id: `ai-assist-${item.key}`,
title: item.label,
parent: 'ai_assist',
section: this.$t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
priority: item,
icon: item.icon,
handler: () => bus.$emit(CMD_AI_ASSIST, item.key),
}));
return [
{
id: 'ai_assist',
title: this.$t('COMMAND_BAR.COMMANDS.AI_ASSIST'),
section: this.$t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
icon: ICON_AI_ASSIST,
children: options.map(option => option.id),
},
...options,
];
},
conversationHotKeys() {
if (isAConversationRoute(this.$route.name)) {
return [
@@ -346,6 +341,7 @@ export default {
...this.assignTeamActions,
...this.labelActions,
...this.assignPriorityActions,
...this.AIAssistActions,
];
}
@@ -48,7 +48,6 @@ export default {
return [
{ key: 'website', name: 'Website' },
{ key: 'facebook', name: 'Messenger' },
{ key: 'twitter', name: 'Twitter' },
{ key: 'whatsapp', name: 'WhatsApp' },
{ key: 'sms', name: 'SMS' },
{ key: 'email', name: 'Email' },
@@ -81,18 +81,45 @@
}}</span>
</label>
</div>
<div class="medium-8 columns messagingServiceHelptext">
<label for="useAPIKey">
<input
id="useAPIKey"
v-model="useAPIKey"
type="checkbox"
class="checkbox"
/>
{{ $t('INBOX_MGMT.ADD.TWILIO.API_KEY.USE_API_KEY') }}
</label>
</div>
<div v-if="useAPIKey" class="medium-8 columns">
<label :class="{ error: $v.apiKeySID.$error }">
{{ $t('INBOX_MGMT.ADD.TWILIO.API_KEY.LABEL') }}
<input
v-model.trim="apiKeySID"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.TWILIO.API_KEY.PLACEHOLDER')"
@blur="$v.apiKeySID.$touch"
/>
<span v-if="$v.apiKeySID.$error" class="message">{{
$t('INBOX_MGMT.ADD.TWILIO.API_KEY.ERROR')
}}</span>
</label>
</div>
<div class="medium-8 columns">
<label :class="{ error: $v.authToken.$error }">
{{ $t('INBOX_MGMT.ADD.TWILIO.AUTH_TOKEN.LABEL') }}
{{ $t(`INBOX_MGMT.ADD.TWILIO.${authTokeni18nKey}.LABEL`) }}
<input
v-model.trim="authToken"
type="text"
:placeholder="$t('INBOX_MGMT.ADD.TWILIO.AUTH_TOKEN.PLACEHOLDER')"
:placeholder="
$t(`INBOX_MGMT.ADD.TWILIO.${authTokeni18nKey}.PLACEHOLDER`)
"
@blur="$v.authToken.$touch"
/>
<span v-if="$v.authToken.$error" class="message">{{
$t('INBOX_MGMT.ADD.TWILIO.AUTH_TOKEN.ERROR')
}}</span>
<span v-if="$v.authToken.$error" class="message">
{{ $t(`INBOX_MGMT.ADD.TWILIO.${authTokeni18nKey}.ERROR`) }}
</span>
</label>
</div>
@@ -123,11 +150,13 @@ export default {
data() {
return {
accountSID: '',
apiKeySID: '',
authToken: '',
medium: this.type,
channelName: '',
messagingServiceSID: '',
useMessagingService: false,
useAPIKey: false,
phoneNumber: '',
};
},
@@ -135,26 +164,39 @@ export default {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
}),
authTokeni18nKey() {
return this.useAPIKey ? 'API_KEY_SECRET' : 'AUTH_TOKEN';
},
},
validations() {
if (this.phoneNumber) {
return {
channelName: { required },
messagingServiceSID: {},
phoneNumber: { required, isPhoneE164OrEmpty },
authToken: { required },
accountSID: { required },
medium: { required },
};
}
return {
let validations = {
channelName: { required },
messagingServiceSID: { required },
phoneNumber: {},
authToken: { required },
accountSID: { required },
medium: { required },
};
if (this.phoneNumber) {
validations = {
...validations,
phoneNumber: { required, isPhoneE164OrEmpty },
messagingServiceSID: {},
};
} else {
validations = {
...validations,
messagingServiceSID: { required },
phoneNumber: {},
};
}
if (this.useAPIKey) {
validations = {
...validations,
apiKeySID: { required },
};
}
return validations;
},
methods: {
async createChannel() {
@@ -171,6 +213,7 @@ export default {
name: this.channelName,
medium: this.medium,
account_sid: this.accountSID,
api_key_sid: this.apiKeySID,
auth_token: this.authToken,
messaging_service_sid: this.messagingServiceSID,
phone_number: this.messagingServiceSID
+2
View File
@@ -42,6 +42,7 @@ import teamMembers from './modules/teamMembers';
import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
import draftMessages from './modules/draftMessages';
import LogRocket from 'logrocket';
import createPlugin from 'logrocket-vuex';
@@ -106,6 +107,7 @@ export default new Vuex.Store({
teams,
userNotificationSettings,
webhooks,
draftMessages,
},
plugins,
});
@@ -49,8 +49,7 @@ const getters = {
},
getSelectedChatAttachments: (_state, _getters) => {
const selectedChat = _getters.getSelectedChat;
const { attachments } = selectedChat;
return attachments;
return selectedChat.attachments || [];
},
getLastEmailInSelectedChat: (stage, _getters) => {
const selectedChat = _getters.getSelectedChat;
@@ -179,6 +179,7 @@ export const mutations = {
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
chat.unread_count = unreadCount;
if (selectedChatId === conversationId) {
window.bus.$emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
window.bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
}
@@ -201,6 +202,7 @@ export const mutations = {
};
Vue.set(allConversations, currentConversationIndex, currentConversation);
if (_state.selectedChatId === conversation.id) {
window.bus.$emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
window.bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
} else {
@@ -0,0 +1,53 @@
import Vue from 'vue';
import types from '../mutation-types';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
const state = {
records: LocalStorage.get(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES) || {},
replyEditorMode: REPLY_EDITOR_MODES.REPLY,
};
export const getters = {
get: _state => key => {
return _state.records[key] || '';
},
getReplyEditorMode: _state => _state.replyEditorMode,
};
export const actions = {
set: async ({ commit }, { key, message }) => {
commit(types.SET_DRAFT_MESSAGES, { key, message });
},
delete: ({ commit }, { key }) => {
commit(types.SET_DRAFT_MESSAGES, { key });
},
setReplyEditorMode: ({ commit }, { mode }) => {
commit(types.SET_REPLY_EDITOR_MODE, { mode });
},
};
export const mutations = {
[types.SET_DRAFT_MESSAGES]($state, { key, message }) {
Vue.set($state.records, key, message);
LocalStorage.set(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES, $state.records);
},
[types.REMOVE_DRAFT_MESSAGES]($state, { key }) {
const { [key]: draftToBeRemoved, ...updatedRecords } = $state.records;
Vue.set($state, 'records', updatedRecords);
LocalStorage.set(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES, $state.records);
},
[types.SET_REPLY_EDITOR_MODE]($state, { mode }) {
Vue.set($state, 'replyEditorMode', mode);
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};
@@ -0,0 +1,76 @@
import axios from 'axios';
import { actions } from '../../draftMessages';
import types from '../../../mutation-types';
const commit = jest.fn();
global.axios = axios;
jest.mock('axios');
describe('#actions', () => {
describe('#set', () => {
it('sends correct actions', async () => {
await actions.set(
{
commit,
state: {
draftMessages: {},
},
},
{ key: 'draft-32-REPLY', message: 'Hey how ' }
);
expect(commit.mock.calls).toEqual([
[
types.SET_DRAFT_MESSAGES,
{
key: 'draft-32-REPLY',
message: 'Hey how ',
},
],
]);
});
});
describe('#delete', () => {
it('sends correct actions', async () => {
await actions.delete(
{
commit,
state: {
draftMessages: {},
},
},
{ key: 'draft-32-REPLY' }
);
expect(commit.mock.calls).toEqual([
[
types.SET_DRAFT_MESSAGES,
{
key: 'draft-32-REPLY',
},
],
]);
});
});
describe('#setReplyEditorMode', () => {
it('sends correct actions', async () => {
await actions.setReplyEditorMode(
{
commit,
state: {
draftMessages: {},
},
},
{ mode: 'reply' }
);
expect(commit.mock.calls).toEqual([
[
types.SET_REPLY_EDITOR_MODE,
{
mode: 'reply',
},
],
]);
});
});
});
@@ -0,0 +1 @@
export const data = { 'draft-32-REPLY': 'Hey how ', 'draft-31-REPLY': 'Nice' };
@@ -0,0 +1,25 @@
import { getters } from '../../draftMessages';
import { data } from './fixtures';
describe('#getters', () => {
it('return the payload if key is present', () => {
const state = {
records: data,
};
expect(getters.get(state)('draft-32-REPLY')).toEqual('Hey how ');
});
it('return empty string if key is not present', () => {
const state = {
records: data,
};
expect(getters.get(state)('draft-22-REPLY')).toEqual('');
});
it('return replyEditorMode', () => {
const state = {
replyEditorMode: 'reply',
};
expect(getters.getReplyEditorMode(state)).toEqual('reply');
});
});
@@ -0,0 +1,45 @@
import types from '../../../mutation-types';
import { mutations } from '../../draftMessages';
describe('#mutations', () => {
describe('#SET_DRAFT_MESSAGES', () => {
it('sets the draft messages', () => {
const state = {
records: {},
};
mutations[types.SET_DRAFT_MESSAGES](state, {
key: 'draft-32-REPLY',
message: 'Hey how ',
});
expect(state.records).toEqual({
'draft-32-REPLY': 'Hey how ',
});
});
});
describe('#REMOVE_DRAFT_MESSAGES', () => {
it('removes the draft messages', () => {
const state = {
records: {
'draft-32-REPLY': 'Hey how ',
},
};
mutations[types.REMOVE_DRAFT_MESSAGES](state, {
key: 'draft-32-REPLY',
});
expect(state.records).toEqual({});
});
});
describe('#SET_REPLY_EDITOR_MODE', () => {
it('sets the reply editor mode', () => {
const state = {
replyEditorMode: 'reply',
};
mutations[types.SET_REPLY_EDITOR_MODE](state, {
mode: 'note',
});
expect(state.replyEditorMode).toEqual('note');
});
});
});
@@ -25,6 +25,9 @@ export default {
SET_CONVERSATION_LAST_SEEN: 'SET_CONVERSATION_LAST_SEEN',
SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION:
'SET_LAST_MESSAGE_ID_FOR_SYNC_CONVERSATION',
SET_DRAFT_MESSAGES: 'SET_DRAFT_MESSAGES',
REMOVE_DRAFT_MESSAGES: 'REMOVE_DRAFT_MESSAGES',
SET_REPLY_EDITOR_MODE: 'SET_REPLY_EDITOR_MODE',
SET_CURRENT_CHAT_WINDOW: 'SET_CURRENT_CHAT_WINDOW',
CLEAR_CURRENT_CHAT_WINDOW: 'CLEAR_CURRENT_CHAT_WINDOW',
@@ -218,5 +218,10 @@
"M6.03866 3.50012L5.92929 8.29274H4.86205L4.75599 3.50012H6.03866ZM5.39567 10.3609C5.1946 10.3609 5.02225 10.2902 4.87863 10.1488C4.73721 10.0074 4.6665 9.83503 4.6665 9.63175C4.6665 9.43289 4.73721 9.26275 4.87863 9.12133C5.02225 8.97992 5.1946 8.90921 5.39567 8.90921C5.59232 8.90921 5.76246 8.97992 5.90609 9.12133C6.05192 9.26275 6.12484 9.43289 6.12484 9.63175C6.12484 9.76654 6.09059 9.88917 6.02209 9.99965C5.9558 10.1101 5.86742 10.1985 5.75694 10.2648C5.64867 10.3289 5.52825 10.3609 5.39567 10.3609Z",
"M8.63887 3.50012L8.5295 8.29274H7.46226L7.3562 3.50012H8.63887ZM7.99588 10.3609C7.79481 10.3609 7.62246 10.2902 7.47883 10.1488C7.33742 10.0074 7.26671 9.83503 7.26671 9.63175C7.26671 9.43289 7.33742 9.26275 7.47883 9.12133C7.62246 8.97992 7.79481 8.90921 7.99588 8.90921C8.19253 8.90921 8.36267 8.97992 8.5063 9.12133C8.65213 9.26275 8.72505 9.43289 8.72505 9.63175C8.72505 9.76654 8.6908 9.88917 8.6223 9.99965C8.55601 10.1101 8.46763 10.1985 8.35715 10.2648C8.24888 10.3289 8.12845 10.3609 7.99588 10.3609Z"
],
"priority-low-outline": "M7.88754 3.5L7.77816 8.29261H6.71093L6.60487 3.5H7.88754ZM7.24455 10.3608C7.04347 10.3608 6.87113 10.2901 6.7275 10.1487C6.58609 10.0073 6.51538 9.83491 6.51538 9.63163C6.51538 9.43277 6.58609 9.26263 6.7275 9.12121C6.87113 8.9798 7.04347 8.90909 7.24455 8.90909C7.4412 8.90909 7.61134 8.9798 7.75496 9.12121C7.9008 9.26263 7.97371 9.43277 7.97371 9.63163C7.97371 9.76641 7.93947 9.88905 7.87097 9.99953C7.80468 10.11 7.7163 10.1984 7.60582 10.2647C7.49755 10.3288 7.37712 10.3608 7.24455 10.3608Z"
"priority-low-outline": "M7.88754 3.5L7.77816 8.29261H6.71093L6.60487 3.5H7.88754ZM7.24455 10.3608C7.04347 10.3608 6.87113 10.2901 6.7275 10.1487C6.58609 10.0073 6.51538 9.83491 6.51538 9.63163C6.51538 9.43277 6.58609 9.26263 6.7275 9.12121C6.87113 8.9798 7.04347 8.90909 7.24455 8.90909C7.4412 8.90909 7.61134 8.9798 7.75496 9.12121C7.9008 9.26263 7.97371 9.43277 7.97371 9.63163C7.97371 9.76641 7.93947 9.88905 7.87097 9.99953C7.80468 10.11 7.7163 10.1984 7.60582 10.2647C7.49755 10.3288 7.37712 10.3608 7.24455 10.3608Z",
"chatwoot-ai-outline": [
"M6 3.99988L5.728 4.01854C5.24919 4.08427 4.81038 4.32115 4.49271 4.68539C4.17505 5.04962 4.00002 5.51656 4 5.99986V7.99984H2L1.728 8.01851C1.24919 8.08423 0.810374 8.32112 0.492709 8.68535C0.175044 9.04959 2.04617e-05 9.51653 0 9.99982L0.0186659 10.2718C0.0843951 10.7506 0.321281 11.1894 0.685517 11.5071C1.04975 11.8248 1.5167 11.9998 2 11.9998H4V13.9998L4.01867 14.2718C4.0844 14.7506 4.32128 15.1894 4.68551 15.5071C5.04975 15.8247 5.5167 15.9997 6 15.9998L6.272 15.9811C7.248 15.8478 8 15.0131 8 13.9998V11.9998H10L10.272 11.9811C11.248 11.8478 12 11.0131 12 9.99982L11.9813 9.72782C11.9156 9.24902 11.6787 8.81021 11.3145 8.49255C10.9502 8.17489 10.4833 7.99986 10 7.99984H8V5.99986L7.98133 5.72786C7.9156 5.24906 7.67872 4.81025 7.31448 4.49258C6.95024 4.17492 6.4833 3.9999 6 3.99988Z",
"M14 0L13.9093 0.00622212C13.7497 0.0281315 13.6035 0.107093 13.4976 0.228504C13.3917 0.349915 13.3333 0.505562 13.3333 0.666661V1.33332H12.6667L12.576 1.33954C12.4164 1.36145 12.2701 1.44041 12.1642 1.56183C12.0584 1.68324 12 1.83888 12 1.99998L12.0062 2.09065C12.0281 2.25025 12.1071 2.39652 12.2285 2.50241C12.3499 2.60829 12.5056 2.66664 12.6667 2.66664H13.3333V3.3333L13.3396 3.42397C13.3615 3.58357 13.4404 3.72984 13.5618 3.83573C13.6833 3.94162 13.8389 3.99996 14 3.99996L14.0907 3.99374C14.416 3.9493 14.6667 3.67108 14.6667 3.3333V2.66664H15.3333L15.424 2.66042C15.7493 2.61598 16 2.33776 16 1.99998L15.9938 1.90932C15.9719 1.74971 15.8929 1.60345 15.7715 1.49756C15.6501 1.39167 15.4944 1.33333 15.3333 1.33332H14.6667V0.666661L14.6605 0.575995C14.6385 0.416393 14.5596 0.270123 14.4382 0.164236C14.3168 0.0583485 14.1611 6.79363e-06 14 0Z",
"M16 12.0001L15.8187 12.0125C15.4995 12.0563 15.2069 12.2143 14.9951 12.4571C14.7834 12.6999 14.6667 13.0112 14.6667 13.3334V14.6667H13.3333L13.152 14.6792C12.8328 14.723 12.5403 14.8809 12.3285 15.1237C12.1167 15.3665 12 15.6778 12 16L12.0124 16.1814C12.0563 16.5006 12.2142 16.7931 12.457 17.0049C12.6998 17.2167 13.0111 17.3333 13.3333 17.3334H14.6667V18.6667L14.6791 18.848C14.7229 19.1672 14.8809 19.4598 15.1237 19.6715C15.3665 19.8833 15.6778 20 16 20L16.1813 19.9876C16.832 19.8987 17.3333 19.3422 17.3333 18.6667V17.3334H18.6667L18.848 17.3209C19.4987 17.232 20 16.6756 20 16L19.9876 15.8187C19.9437 15.4995 19.7858 15.207 19.543 14.9952C19.3002 14.7834 18.9889 14.6667 18.6667 14.6667H17.3333V13.3334L17.3209 13.1521C17.2771 12.8329 17.1191 12.5403 16.8763 12.3285C16.6335 12.1168 16.3222 12.0001 16 12.0001Z"
]
}
@@ -4,6 +4,8 @@ export const BUS_EVENTS = {
START_NEW_CONVERSATION: 'START_NEW_CONVERSATION',
FOCUS_CUSTOM_ATTRIBUTE: 'FOCUS_CUSTOM_ATTRIBUTE',
SCROLL_TO_MESSAGE: 'SCROLL_TO_MESSAGE',
MESSAGE_SENT: 'MESSAGE_SENT',
FETCH_LABEL_SUGGESTIONS: 'FETCH_LABEL_SUGGESTIONS',
TOGGLE_SIDEMENU: 'TOGGLE_SIDEMENU',
ON_MESSAGE_LIST_SCROLL: 'ON_MESSAGE_LIST_SCROLL',
WEBSOCKET_DISCONNECT: 'WEBSOCKET_DISCONNECT',
+11
View File
@@ -0,0 +1,11 @@
export const OPEN_AI_OPTIONS = {
IMPROVE_WRITING: 'improve_writing',
FIX_SPELLING_GRAMMAR: 'fix_spelling_grammar',
SHORTEN: 'shorten',
EXPAND: 'expand',
MAKE_FRIENDLY: 'make_friendly',
MAKE_FORMAL: 'make_formal',
SIMPLIFY: 'simplify',
REPLY_SUGGESTION: 'reply_suggestion',
SUMMARIZE: 'summarize',
};
+1 -1
View File
@@ -22,7 +22,7 @@
'focus:ring-red-600 ring-red-600': hasError,
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200': !hasError,
}"
class="block w-full rounded-md border-0 px-3 py-3 shadow-sm ring-1 ring-inset text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 outline-none dark:bg-slate-700 "
class="block w-full rounded-md border-0 px-3 py-3 appearance-none shadow-sm ring-1 ring-inset text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 outline-none dark:bg-slate-700 "
@input="onInput"
@blur="$emit('blur')"
/>
+17 -9
View File
@@ -1,6 +1,6 @@
<template>
<main
class="flex flex-col bg-woot-25 min-h-full w-full py-12 sm:px-6 lg:px-8 justify-center dark:bg-slate-900"
class="flex flex-col bg-woot-25 min-h-screen w-full py-20 sm:px-6 lg:px-8 dark:bg-slate-900"
>
<section class="max-w-5xl mx-auto">
<img
@@ -26,14 +26,17 @@
class="mt-3 text-center text-sm text-slate-600 dark:text-slate-400"
>
{{ $t('COMMON.OR') }}
<router-link to="auth/signup" class="text-link">
<router-link to="auth/signup" class="text-link lowercase">
{{ $t('LOGIN.CREATE_NEW_ACCOUNT') }}
</router-link>
</p>
</section>
<section
class="sm:mx-auto mt-11 sm:w-full sm:max-w-lg bg-white dark:bg-slate-800 p-11 shadow sm:shadow-lg sm:rounded-lg"
:class="{ 'mb-8 mt-15': !showGoogleOAuth }"
class="bg-white shadow sm:mx-auto mt-11 sm:w-full sm:max-w-lg dark:bg-slate-800 p-11 sm:shadow-lg sm:rounded-lg"
:class="{
'mb-8 mt-15': !showGoogleOAuth,
'animate-wiggle': loginApi.hasErrored,
}"
>
<div v-if="!email">
<GoogleOAuthButton v-if="showGoogleOAuth" />
@@ -67,11 +70,7 @@
</p>
</form-input>
<submit-button
:disabled="
$v.credentials.email.$invalid ||
$v.credentials.password.$invalid ||
loginApi.showLoading
"
:disabled="loginApi.showLoading"
:button-text="$t('LOGIN.SUBMIT')"
:loading="loginApi.showLoading"
/>
@@ -126,6 +125,7 @@ export default {
loginApi: {
message: '',
showLoading: false,
hasErrored: false,
},
error: '',
};
@@ -173,7 +173,14 @@ export default {
bus.$emit('newToastMessage', this.loginApi.message);
},
submitLogin() {
if (this.$v.credentials.email.$invalid && !this.email) {
this.showAlert(this.$t('LOGIN.EMAIL.ERROR'));
return;
}
this.loginApi.hasErrored = false;
this.loginApi.showLoading = true;
const credentials = {
email: this.email
? decodeURIComponent(this.email)
@@ -193,6 +200,7 @@ export default {
if (this.email) {
window.location = '/app/login';
}
this.loginApi.hasErrored = true;
this.showAlert(response?.message || this.$t('LOGIN.API.UNAUTH'));
});
},
@@ -25,3 +25,23 @@ export const getExtraSpaceToScroll = () => {
return extraHeight;
};
export const shouldTriggerMessageUpdateEvent = message => {
const { previous_changes: previousChanges } = message;
if (!previousChanges) {
return false;
}
const hasNotifiableAttributeChanges = Object.keys(previousChanges).includes(
'content_attributes'
);
if (!hasNotifiableAttributeChanges) {
return false;
}
const hasSubmittedValueChanges = Object.keys(
previousChanges.content_attributes[1] || {}
).includes('submitted_values');
return hasSubmittedValueChanges;
};
@@ -2,6 +2,7 @@ import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnec
import { playNewMessageNotificationInWidget } from 'widget/helpers/WidgetAudioNotificationHelper';
import { ON_AGENT_MESSAGE_RECEIVED } from '../constants/widgetBusEvents';
import { IFrameHelper } from 'widget/helpers/utils';
import { shouldTriggerMessageUpdateEvent } from './IframeEventHelper';
const isMessageInActiveConversation = (getters, message) => {
const { conversation_id: conversationId } = message;
@@ -67,6 +68,11 @@ class ActionCableConnector extends BaseActionCableConnector {
if (isMessageInActiveConversation(this.app.$store.getters, data)) {
return;
}
if (shouldTriggerMessageUpdateEvent(data)) {
IFrameHelper.sendMessage({ event: 'onMessage', data });
}
this.app.$store.dispatch('conversation/addOrUpdateMessage', data);
};
+1 -1
View File
@@ -29,7 +29,7 @@ class ActionCableListener < BaseListener
conversation = message.conversation
tokens = user_tokens(account, conversation.inbox.members) + contact_tokens(conversation.contact_inbox, message)
broadcast(account, tokens, MESSAGE_UPDATED, message.push_event_data)
broadcast(account, tokens, MESSAGE_UPDATED, message.push_event_data.merge(previous_changes: event.data[:previous_changes]))
end
def first_reply_created(event)
+7 -1
View File
@@ -4,6 +4,7 @@
#
# id :bigint not null, primary key
# account_sid :string not null
# api_key_sid :string
# auth_token :string not null
# medium :integer default("sms")
# messaging_service_sid :string
@@ -25,6 +26,7 @@ class Channel::TwilioSms < ApplicationRecord
self.table_name = 'channel_twilio_sms'
validates :account_sid, presence: true
# The same parameter is used to store api_key_secret if api_key authentication is opted
validates :auth_token, presence: true
# Must have _one_ of messaging_service_sid _or_ phone_number, and messaging_service_sid is preferred
@@ -51,7 +53,11 @@ class Channel::TwilioSms < ApplicationRecord
private
def client
::Twilio::REST::Client.new(account_sid, auth_token)
if api_key_sid.present?
Twilio::REST::Client.new(api_key_sid, auth_token, account_sid)
else
Twilio::REST::Client.new(account_sid, auth_token)
end
end
def send_message_from
+1
View File
@@ -304,3 +304,4 @@ class Conversation < ApplicationRecord
end
Conversation.include_mod_with('EnterpriseConversationConcern')
Conversation.include_mod_with('SentimentAnalysisHelper')
+21 -7
View File
@@ -12,6 +12,7 @@
# private :boolean default(FALSE)
# processed_message_content :text
# sender_type :string
# sentiment :jsonb
# status :integer default("sent")
# created_at :datetime not null
# updated_at :datetime not null
@@ -135,21 +136,26 @@ class Message < ApplicationRecord
end
def push_event_data
data = attributes.merge(
data = attributes.symbolize_keys.merge(
created_at: created_at.to_i,
message_type: message_type_before_type_cast,
conversation_id: conversation.display_id,
conversation: {
assignee_id: conversation.assignee_id,
unread_count: conversation.unread_incoming_messages.count,
last_activity_at: conversation.last_activity_at.to_i
}
conversation: conversation_push_event_data
)
data.merge!(echo_id: echo_id) if echo_id.present?
data.merge!(attachments: attachments.map(&:push_event_data)) if attachments.present?
merge_sender_attributes(data)
end
def conversation_push_event_data
{
assignee_id: conversation.assignee_id,
unread_count: conversation.unread_incoming_messages.count,
last_activity_at: conversation.last_activity_at.to_i,
contact_inbox: { source_id: conversation.contact_inbox.source_id }
}
end
# TODO: We will be removing this code after instagram_manage_insights is implemented
# Better logic is to listen to webhook and remove stories proactively rather than trying
# a fetch every time a message is returned
@@ -241,6 +247,7 @@ class Message < ApplicationRecord
reopen_conversation
notify_via_mail
set_conversation_activity
update_message_sentiments
dispatch_create_events
send_reply
execute_message_template_hooks
@@ -276,7 +283,8 @@ class Message < ApplicationRecord
end
def dispatch_update_event
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by)
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by,
previous_changes: previous_changes)
end
def send_reply
@@ -365,4 +373,10 @@ class Message < ApplicationRecord
conversation.update_columns(last_activity_at: created_at)
# rubocop:enable Rails/SkipsModelValidations
end
def update_message_sentiments
# override in the enterprise ::Enterprise::SentimentAnalysisJob.perform_later(self)
end
end
Message.prepend_mod_with('Message')
@@ -17,17 +17,21 @@
<div>
<a
class="text-slate-700 hover:underline leading-8 text-sm font-semibold"
href="/hc/<%= @portal.slug %>/<%= @article.category.locale %>"
href="/hc/<%= @portal.slug %>/<%= @article.category&.locale %>"
>
Home
</a>
<span class="text-xs text-slate-600 px-1">/</span>
<a
class="text-slate-700 hover:underline leading-8 text-sm font-semibold"
href="/hc/<%= @portal.slug %>/<%= @article.category.locale %>/categories/<%= @article.category.slug %>"
>
<%= @article.category.name %>
</a>
<% if @article.category %>
<a
class="text-slate-700 hover:underline leading-8 text-sm font-semibold"
href="/hc/<%= @portal.slug %>/<%= @article.category.locale %>/categories/<%= @article.category.slug %>"
>
<%= @article.category&.name %>
</a>
<% else %>
<span class="text-slate-700 leading-8 text-sm font-semibold" > Uncategorized </span>
<% end %>
</div>
<h1 class="text-3xl font-semibold md:tracking-normal leading-snug md:text-4xl text-slate-900">
<%= @article.title %>
@@ -0,0 +1,5 @@
class SentimentColumnToMessages < ActiveRecord::Migration[7.0]
def change
add_column :messages, :sentiment, :jsonb, default: {}
end
end
@@ -0,0 +1,5 @@
class AddApiKeySidToTwilioSms < ActiveRecord::Migration[7.0]
def change
add_column :channel_twilio_sms, :api_key_sid, :string
end
end
+3 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_06_20_212340) do
ActiveRecord::Schema[7.0].define(version: 2023_07_14_054138) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -330,6 +330,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_06_20_212340) do
t.datetime "updated_at", null: false
t.integer "medium", default: 0
t.string "messaging_service_sid"
t.string "api_key_sid"
t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true
t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true
t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
@@ -669,6 +670,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_06_20_212340) do
t.jsonb "external_source_ids", default: {}
t.jsonb "additional_attributes", default: {}
t.text "processed_message_content"
t.jsonb "sentiment", default: {}
t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin
t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id"
t.index ["account_id"], name: "index_messages_on_account_id"
@@ -0,0 +1,41 @@
class Enterprise::SentimentAnalysisJob < ApplicationJob
queue_as :default
def perform(message)
return if message.account.locale != 'en'
return if valid_incoming_message?(message)
save_message_sentiment(message)
rescue StandardError => e
Rails.logger.error("Sentiment Analysis Error for message #{message.id}: #{e}")
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
end
def save_message_sentiment(message)
# We are truncating the data here to avoind the OnnxRuntime::Error
# Indices element out of data bounds, idx=512 must be within the inclusive range [-512,511]
# While gathering the maningfull node the Array/tensor index is going out of bound
text = message.content&.truncate(2900)
return if model.blank?
sentiment = model.predict(text)
message.sentiment = sentiment.merge(value: label_val(sentiment))
message.save!
end
# Model initializes OnnxRuntime::Model, with given file for inference session and to create the tensor
def model
model_path = ENV.fetch('SENTIMENT_FILE_PATH', nil)
Informers::SentimentAnalysis.new(model_path) if model_path.present?
end
def label_val(sentiment)
sentiment[:label] == 'positive' ? 1 : -1
end
def valid_incoming_message?(message)
!message.incoming? || message.private?
end
end
@@ -0,0 +1,5 @@
module Enterprise::Message
def update_message_sentiments
::Enterprise::SentimentAnalysisJob.perform_later(self)
end
end
@@ -0,0 +1,45 @@
module Enterprise::SentimentAnalysisHelper
extend ActiveSupport::Concern
included do
def opening_sentiments
records = incoming_messages.first(average_message_count)
average_sentiment(records)
end
def closing_sentiments
return unless resolved?
records = incoming_messages.last(average_message_count)
average_sentiment(records)
end
def average_sentiment(records)
{
label: average_sentiment_label(records),
score: average_sentiment_score(records)
}
end
private
def average_sentiment_label(records)
value = records.pluck(:sentiment).sum { |a| a['value'].to_i }
value.negative? ? 'negative' : 'positive'
end
def average_sentiment_score(records)
total = records.pluck(:sentiment).sum { |a| a['score'].to_f }
total / average_message_count
end
def average_message_count
# incoming_messages.count >= 10 ? 5 : ((incoming_messages.count / 2) - 1)
5
end
def incoming_messages
messages.incoming.where(private: false)
end
end
end
@@ -1,5 +1,6 @@
module Enterprise::Integrations::OpenaiProcessorService
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion label_suggestion].freeze
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion label_suggestion fix_spelling_grammar shorten expand
make_friendly make_formal simplify].freeze
CACHEABLE_EVENTS = %w[label_suggestion].freeze
def label_suggestion_message
@@ -17,6 +18,10 @@ module Enterprise::Integrations::OpenaiProcessorService
private
def prompt_from_file(file_name)
Rails.root.join('enterprise/lib/enterprise/integrations/openai_prompts', "#{file_name}.txt").read
end
def labels_with_messages
labels = hook.account.labels.pluck(:title).join(', ')
@@ -30,6 +35,17 @@ module Enterprise::Integrations::OpenaiProcessorService
"Messages:\n#{messages}\nLabels:\n#{labels}"
end
def summarize_body
{
model: self.class::GPT_MODEL,
messages: [
{ role: 'system',
content: prompt_from_file('summary') },
{ role: 'user', content: conversation_messages }
]
}.to_json
end
def label_suggestion_body
content = labels_with_messages
return nil if content.blank?
@@ -39,13 +55,7 @@ module Enterprise::Integrations::OpenaiProcessorService
messages: [
{
role: 'system',
content: 'Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a ' \
'customer and the support agent, along with a list of potential labels. ' \
'Your task is to analyze the conversation and select the two labels from the given list that most accurately ' \
'represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. ' \
'Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response ' \
'as a comma-separated list of the provided labels. Remember, your response should only contain the labels you\'ve selected, ' \
'in their original casing, and nothing else. '
content: prompt_from_file('label_suggestion')
},
{ role: 'user', content: content }
]
@@ -0,0 +1 @@
Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a customer and the support agent, along with a list of potential labels. Your task is to analyze the conversation and select the two labels from the given list that most accurately represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain the labels you\'ve selected,in their original casing, and nothing else.
@@ -0,0 +1,28 @@
As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
Make sure you strongly adhere to the following rules when generating the summary
1. Be brief and concise. The shorter the summary the better.
2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
3. Describe the customer intent in around 50 words.
4. Remove information that is not directly relevant to the customer's problem or the agent's solution. For example, personal anecdotes, small talk, etc.
5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell.
6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
7. 'Action Items' should strictly encapsulate tasks committed to by the agent or left incomplete. Any suggestions made by the agent should not be included.
8. The 'Action Items' should be brief and concise
9. Mark important words or parts of sentences as bold.
10. Apply markdown syntax to format any included code, using backticks.
11. Include a section for "Follow-up Items" or "Open Questions" if there are any unresolved issues or outstanding questions.
12. If any section does not have any content, remove that section and the heading from the response
13. Do not insert your own opinions about the conversation.
Reply in the user's language, as a markdown of the following format.
**Customer Intent**
**Conversation Summary**
**Action Items**
**Follow-up Items**
+58 -4
View File
@@ -1,4 +1,6 @@
class Integrations::Openai::ProcessorService < Integrations::OpenaiBaseService
AGENT_INSTRUCTION = 'You are a helpful support agent.'.freeze
LANGUAGE_INSTRUCTION = 'Ensure that the reply should be in user language.'.freeze
def reply_suggestion_message
make_api_call(reply_suggestion_body)
end
@@ -11,16 +13,68 @@ class Integrations::Openai::ProcessorService < Integrations::OpenaiBaseService
make_api_call(rephrase_body)
end
def fix_spelling_grammar_message
make_api_call(fix_spelling_grammar_body)
end
def shorten_message
make_api_call(shorten_body)
end
def expand_message
make_api_call(expand_body)
end
def make_friendly_message
make_api_call(make_friendly_body)
end
def make_formal_message
make_api_call(make_formal_body)
end
def simplify_message
make_api_call(simplify_body)
end
private
def rephrase_body
build_api_call_body("#{AGENT_INSTRUCTION} Please rephrase the following response. " \
"#{LANGUAGE_INSTRUCTION}")
end
def fix_spelling_grammar_body
build_api_call_body("#{AGENT_INSTRUCTION} Please fix the spelling and grammar of the following response. " \
"#{LANGUAGE_INSTRUCTION}")
end
def shorten_body
build_api_call_body("#{AGENT_INSTRUCTION} Please shorten the following response. #{LANGUAGE_INSTRUCTION}")
end
def expand_body
build_api_call_body("#{AGENT_INSTRUCTION} Please expand the following response. #{LANGUAGE_INSTRUCTION}")
end
def make_friendly_body
build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more friendly. #{LANGUAGE_INSTRUCTION}")
end
def make_formal_body
build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more formal. #{LANGUAGE_INSTRUCTION}")
end
def simplify_body
build_api_call_body("#{AGENT_INSTRUCTION} Please simplify the following response. #{LANGUAGE_INSTRUCTION}")
end
def build_api_call_body(system_content, user_content = event['data']['content'])
{
model: GPT_MODEL,
messages: [
{ role: 'system',
content: "You are a helpful support agent. Please rephrase the following response to a more #{event['data']['tone']} tone. " \
"Reply in the user's language." },
{ role: 'user', content: event['data']['content'] }
{ role: 'system', content: system_content },
{ role: 'user', content: user_content }
]
}.to_json
end
+1 -1
View File
@@ -6,7 +6,7 @@ class Integrations::OpenaiBaseService
API_URL = 'https://api.openai.com/v1/chat/completions'.freeze
GPT_MODEL = 'gpt-3.5-turbo'.freeze
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion].freeze
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze
CACHEABLE_EVENTS = %w[].freeze
pattr_initialize [:hook!, :event!]
@@ -0,0 +1,82 @@
require 'rails_helper'
RSpec.describe Enterprise::SentimentAnalysisJob do
context 'when account locale set to english language' do
let(:account) { create(:account, locale: 'en') }
let(:message) { build(:message, content_type: nil, account: account) }
context 'when update the message sentiments' do
let(:model_path) { 'sentiment-analysis.onnx' }
let(:model) { double }
before do
allow(Informers::SentimentAnalysis).to receive(:new).with(model_path).and_return(model)
allow(model).to receive(:predict).and_return({ label: 'positive', score: '0.6' })
end
it 'with incoming message' do
with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do
message.update(message_type: :incoming)
described_class.perform_now(message)
expect(message.sentiment).not_to be_empty
end
end
it 'update sentiment label for positive message' do
with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do
message.update(message_type: :incoming, content: 'I like your product')
described_class.perform_now(message)
expect(message.sentiment).not_to be_empty
expect(message.sentiment['label']).to eq('positive')
expect(message.sentiment['value']).to eq(1)
end
end
it 'update sentiment label for negative message' do
with_modified_env SENTIMENT_FILE_PATH: 'sentiment-analysis.onnx' do
message.update(message_type: :incoming, content: 'I did not like your product')
allow(model).to receive(:predict).and_return({ label: 'negative', score: '0.6' })
described_class.perform_now(message)
expect(message.sentiment).not_to be_empty
expect(message.sentiment['label']).to eq('negative')
expect(message.sentiment['value']).to eq(-1)
end
end
end
context 'when does not update the message sentiments' do
it 'with outgoing message' do
message.update(message_type: :outgoing)
described_class.perform_now(message)
expect(message.sentiment).to be_empty
end
it 'with private message' do
message.update(private: true)
described_class.perform_now(message)
expect(message.sentiment).to be_empty
end
end
end
context 'when account locale is not set to english language' do
let(:account) { create(:account, locale: 'es') }
let(:message) { build(:message, content_type: nil, account: account) }
it 'does not update the message sentiments' do
described_class.perform_now(message)
expect(message.sentiment).to be_empty
end
end
end
@@ -4,4 +4,34 @@ RSpec.describe Conversation, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:sla_policy).optional }
end
describe 'conversation sentiments' do
include ActiveJob::TestHelper
let(:conversation) { create(:conversation, additional_attributes: { referer: 'https://www.chatwoot.com/' }) }
before do
10.times do
message = create(:message, conversation_id: conversation.id, account_id: conversation.account_id, message_type: 'incoming')
message.update(sentiment: { 'label': 'positive', score: '0.4' })
end
end
it 'returns opening sentiments' do
sentiments = conversation.opening_sentiments
expect(sentiments[:label]).to eq('positive')
end
it 'returns closing sentiments if conversation is not resolved' do
sentiments = conversation.closing_sentiments
expect(sentiments).to be_nil
end
it 'returns closing sentiments if it is resolved' do
conversation.resolved!
sentiments = conversation.closing_sentiments
expect(sentiments[:label]).to eq('positive')
end
end
end
+18
View File
@@ -0,0 +1,18 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/liquidable_shared.rb'
RSpec.describe Message do
context 'with sentiment analysis' do
let(:message) { build(:message, message_type: :incoming, content_type: nil, account: create(:account)) }
it 'calls SentimentAnalysisJob' do
allow(Enterprise::SentimentAnalysisJob).to receive(:perform_later).and_return(:perform_later).with(message)
message.save!
expect(Enterprise::SentimentAnalysisJob).to have_received(:perform_later)
end
end
end
@@ -25,7 +25,7 @@ describe Integrations::Dyte::ProcessorService do
it 'creates an integration message in the conversation' do
response = processor.create_a_meeting(agent)
expect(response['content']).to eq("#{agent.available_name} has started a meeting")
expect(response[:content]).to eq("#{agent.available_name} has started a meeting")
expect(conversation.reload.messages.last.content_type).to eq('integrations')
end
end
@@ -20,6 +20,14 @@ RSpec.describe Integrations::Openai::ProcessorService do
let!(:conversation) { create(:conversation, account: account) }
let!(:customer_message) { create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent') }
let!(:agent_message) { create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer') }
let!(:summary_prompt) do
if Dir.exist?('enterprise')
Rails.root.join('enterprise/lib/enterprise/integrations/openai_prompts/summary.txt').read
else
'Please summarize the key points from the following conversation between support agents and customer as bullet points for the next ' \
"support agent looking into the conversation. Reply in the user's language."
end
end
describe '#perform' do
context 'when event name is rephrase' do
@@ -32,8 +40,8 @@ RSpec.describe Integrations::Openai::ProcessorService do
{
'role' => 'system',
'content' => 'You are a helpful support agent. ' \
'Please rephrase the following response to a more ' \
"#{event['data']['tone']} tone. Reply in the user's language."
'Please rephrase the following response. ' \
'Ensure that the reply should be in user language.'
},
{ 'role' => 'user', 'content' => event['data']['content'] }
]
@@ -83,8 +91,7 @@ RSpec.describe Integrations::Openai::ProcessorService do
'model' => 'gpt-3.5-turbo',
'messages' => [
{ 'role' => 'system',
'content' => 'Please summarize the key points from the following conversation between support agents and customer ' \
'as bullet points for the next support agent looking into the conversation. Reply in the user\'s language.' },
'content' => summary_prompt },
{ 'role' => 'user', 'content' => conversation_messages }
]
}.to_json
@@ -97,5 +104,154 @@ RSpec.describe Integrations::Openai::ProcessorService do
expect(result).to eq('This is a reply from openai.')
end
end
context 'when event name is label_suggestion with no labels' do
let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
it 'returns nil' do
result = subject.perform
expect(result).to be_nil
end
end
context 'when event name is not one that can be processed' do
let(:event) { { 'name' => 'unknown', 'data' => {} } }
it 'returns nil' do
expect(subject.perform).to be_nil
end
end
context 'when event name is fix_spelling_grammar' do
let(:event) { { 'name' => 'fix_spelling_grammar', 'data' => { 'content' => 'This is a test' } } }
it 'returns the corrected text' do
request_body = {
'model' => 'gpt-3.5-turbo',
'messages' => [
{ 'role' => 'system', 'content' => 'You are a helpful support agent. Please fix the spelling and grammar of the following response. ' \
'Ensure that the reply should be in user language.' },
{ 'role' => 'user', 'content' => event['data']['content'] }
]
}.to_json
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.with(body: request_body, headers: expected_headers)
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
end
end
context 'when event name is shorten' do
let(:event) { { 'name' => 'shorten', 'data' => { 'content' => 'This is a test' } } }
it 'returns the shortened text' do
request_body = {
'model' => 'gpt-3.5-turbo',
'messages' => [
{ 'role' => 'system', 'content' => 'You are a helpful support agent. Please shorten the following response. ' \
'Ensure that the reply should be in user language.' },
{ 'role' => 'user', 'content' => event['data']['content'] }
]
}.to_json
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.with(body: request_body, headers: expected_headers)
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
end
end
context 'when event name is expand' do
let(:event) { { 'name' => 'expand', 'data' => { 'content' => 'help you' } } }
it 'returns the expanded text' do
request_body = {
'model' => 'gpt-3.5-turbo',
'messages' => [
{ 'role' => 'system', 'content' => 'You are a helpful support agent. Please expand the following response. ' \
'Ensure that the reply should be in user language.' },
{ 'role' => 'user', 'content' => event['data']['content'] }
]
}.to_json
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.with(body: request_body, headers: expected_headers)
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
end
end
context 'when event name is make_friendly' do
let(:event) { { 'name' => 'make_friendly', 'data' => { 'content' => 'This is a test' } } }
it 'returns the friendly text' do
request_body = {
'model' => 'gpt-3.5-turbo',
'messages' => [
{ 'role' => 'system', 'content' => 'You are a helpful support agent. Please make the following response more friendly. ' \
'Ensure that the reply should be in user language.' },
{ 'role' => 'user', 'content' => event['data']['content'] }
]
}.to_json
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.with(body: request_body, headers: expected_headers)
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
end
end
context 'when event name is make_formal' do
let(:event) { { 'name' => 'make_formal', 'data' => { 'content' => 'This is a test' } } }
it 'returns the formal text' do
request_body = {
'model' => 'gpt-3.5-turbo',
'messages' => [
{ 'role' => 'system', 'content' => 'You are a helpful support agent. Please make the following response more formal. ' \
'Ensure that the reply should be in user language.' },
{ 'role' => 'user', 'content' => event['data']['content'] }
]
}.to_json
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.with(body: request_body, headers: expected_headers)
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
end
end
context 'when event name is simplify' do
let(:event) { { 'name' => 'simplify', 'data' => { 'content' => 'This is a test' } } }
it 'returns the simplified text' do
request_body = {
'model' => 'gpt-3.5-turbo',
'messages' => [
{ 'role' => 'system', 'content' => 'You are a helpful support agent. Please simplify the following response. ' \
'Ensure that the reply should be in user language.' },
{ 'role' => 'user', 'content' => event['data']['content'] }
]
}.to_json
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.with(body: request_body, headers: expected_headers)
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a reply from openai.')
end
end
end
end
+45
View File
@@ -50,6 +50,51 @@ RSpec.describe Message do
end
end
describe '#push_event_data' do
subject(:push_event_data) { message.push_event_data }
let(:message) { create(:message, echo_id: 'random-echo_id') }
let(:expected_data) do
{
account_id: message.account_id,
additional_attributes: message.additional_attributes,
content_attributes: message.content_attributes,
content_type: message.content_type,
content: message.content,
conversation_id: message.conversation.display_id,
created_at: message.created_at.to_i,
external_source_ids: message.external_source_ids,
id: message.id,
inbox_id: message.inbox_id,
message_type: message.message_type_before_type_cast,
private: message.private,
processed_message_content: message.processed_message_content,
sender_id: message.sender_id,
sender_type: message.sender_type,
source_id: message.source_id,
status: message.status,
updated_at: message.updated_at,
conversation: {
assignee_id: message.conversation.assignee_id,
contact_inbox: {
source_id: message.conversation.contact_inbox.source_id
},
last_activity_at: message.conversation.last_activity_at.to_i,
unread_count: message.conversation.unread_incoming_messages.count
},
sentiment: {},
sender: message.sender.push_event_data,
echo_id: 'random-echo_id'
}
end
it 'returns push event payload' do
expect(push_event_data).to eq(expected_data)
end
end
describe 'Check if message is a valid first reply' do
it 'is valid if it is outgoing' do
outgoing_message = create(:message, message_type: :outgoing)
+15
View File
@@ -97,6 +97,21 @@ module.exports = {
},
body: '#2f3b49',
},
keyframes: {
wiggle: {
'0%': { transform: 'translateX(0)' },
'15%': { transform: 'translateX(0.375rem)' },
'30%': { transform: 'translateX(-0.375rem)' },
'45%': { transform: 'translateX(0.375rem)' },
'60%': { transform: 'translateX(-0.375rem)' },
'75%': { transform: 'translateX(0.375rem)' },
'90%': { transform: 'translateX(-0.375rem)' },
'100%': { transform: 'translateX(0)' },
},
},
animation: {
wiggle: 'wiggle 0.5s ease-in-out',
},
},
plugins: [
// eslint-disable-next-line