Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e75ac2de5 | ||
|
|
91c1061214 | ||
|
|
ec65b43993 | ||
|
|
1a81245987 | ||
|
|
2c3ad73542 | ||
|
|
59b1d59574 | ||
|
|
9de2edd300 | ||
|
|
66751b71dd | ||
|
|
7e415fbee3 | ||
|
|
50a927bac2 | ||
|
|
19ff738211 | ||
|
|
7c080fa9fa | ||
|
|
91e2da5e74 | ||
|
|
21691f2407 | ||
|
|
d495411ef5 | ||
|
|
918e1f25c2 | ||
|
|
10dd0ba647 | ||
|
|
550bea099c | ||
|
|
c7bf93ef8a | ||
|
|
ec7434d000 | ||
|
|
941e7e06cf | ||
|
|
09f46aa912 | ||
|
|
332ab5888c |
@@ -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=
|
||||
|
||||
@@ -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 ###
|
||||
##############################################################
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+275
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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')"
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -304,3 +304,4 @@ class Conversation < ApplicationRecord
|
||||
end
|
||||
|
||||
Conversation.include_mod_with('EnterpriseConversationConcern')
|
||||
Conversation.include_mod_with('SentimentAnalysisHelper')
|
||||
|
||||
+21
-7
@@ -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
@@ -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**
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user