feat: label suggestion UI (#7480)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,16 +70,15 @@
|
||||
</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 aiMixin from 'dashboard/mixins/aiMixin';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
export default {
|
||||
mixins: [alertMixin, clickaway, eventListenerMixins],
|
||||
mixins: [aiMixin, alertMixin, clickaway, eventListenerMixins],
|
||||
props: {
|
||||
conversationId: {
|
||||
type: Number,
|
||||
@@ -119,29 +118,12 @@ export default {
|
||||
};
|
||||
},
|
||||
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');
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (!this.appIntegrations.length) {
|
||||
this.$store.dispatch('integrations/get');
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onKeyDownHandler(event) {
|
||||
const keyPattern = buildHotKeys(event);
|
||||
@@ -159,15 +141,6 @@ export default {
|
||||
closeDropdown() {
|
||||
this.showDropdown = false;
|
||||
},
|
||||
async recordAnalytics({ type, tone }) {
|
||||
const event = OPEN_AI_EVENTS[type.toUpperCase()];
|
||||
if (event) {
|
||||
this.$track(event, {
|
||||
type,
|
||||
tone,
|
||||
});
|
||||
}
|
||||
},
|
||||
async processEvent(type = 'rephrase') {
|
||||
this.uiFlags[type] = true;
|
||||
try {
|
||||
@@ -184,7 +157,7 @@ export default {
|
||||
this.initialMessage = this.message;
|
||||
this.$emit('replace-text', generatedMessage || this.message);
|
||||
this.closeDropdown();
|
||||
this.recordAnalytics({ type, tone: this.activeTone });
|
||||
this.recordAnalytics(type, { tone: this.activeTone });
|
||||
} catch (error) {
|
||||
this.showAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'));
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="avatar-container" :style="style" aria-hidden="true">
|
||||
{{ userInitial }}
|
||||
<slot>{{ userInitial }}</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
:has-instagram-story="hasInstagramStory"
|
||||
:is-web-widget-inbox="isAWebWidgetInbox"
|
||||
/>
|
||||
<conversation-label-suggestion
|
||||
v-if="isEnterprise && isAIIntegrationEnabled"
|
||||
:suggested-labels="labelSuggestions"
|
||||
:chat-labels="currentChat.labels"
|
||||
:conversation-id="currentChat.id"
|
||||
/>
|
||||
</ul>
|
||||
<div
|
||||
class="conversation-footer"
|
||||
@@ -91,29 +97,47 @@
|
||||
</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';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Message,
|
||||
ReplyBox,
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
},
|
||||
mixins: [conversationMixin, inboxMixin, eventListenerMixins],
|
||||
mixins: [
|
||||
conversationMixin,
|
||||
inboxMixin,
|
||||
eventListenerMixins,
|
||||
configMixin,
|
||||
aiMixin,
|
||||
],
|
||||
props: {
|
||||
isContactPanelOpen: {
|
||||
type: Boolean,
|
||||
@@ -127,7 +151,10 @@ export default {
|
||||
heightBeforeLoad: null,
|
||||
conversationPanel: null,
|
||||
selectedTweetId: null,
|
||||
hasUserScrolled: false,
|
||||
isProgrammaticScroll: false,
|
||||
isPopoutReplyBox: false,
|
||||
labelSuggestions: [],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -138,6 +165,8 @@ export default {
|
||||
inboxesList: 'inboxes/getInboxes',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
loadingChatList: 'getChatListLoadingStatus',
|
||||
appIntegrations: 'integrations/getAppIntegrations',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
inboxId() {
|
||||
return this.currentChat.inbox_id;
|
||||
@@ -280,18 +309,22 @@ export default {
|
||||
return;
|
||||
}
|
||||
this.fetchAllAttachmentsFromCurrentChat();
|
||||
this.fetchSuggestions();
|
||||
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);
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.addScrollListener();
|
||||
this.fetchAllAttachmentsFromCurrentChat();
|
||||
this.fetchSuggestions();
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
@@ -300,6 +333,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 +390,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 +421,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 +495,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);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<li v-if="shouldShowSuggestions" class="label-suggestion right">
|
||||
<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"
|
||||
class="label--add"
|
||||
icon="dismiss"
|
||||
size="tiny"
|
||||
@click="dismissSuggestions"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="preparedLabels.length > 1">
|
||||
<woot-button
|
||||
variant="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,
|
||||
}"
|
||||
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: () => [],
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDismissed: false,
|
||||
fetchingSuggestions: 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,6 @@ export const OPEN_AI_EVENTS = Object.freeze({
|
||||
SUMMARIZE: 'OpenAI: Used summarize',
|
||||
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
|
||||
REPHRASE: 'OpenAI: Used rephrase',
|
||||
APPLY_LABEL_SUGGESTION: 'OpenAI: Apply label from suggestion',
|
||||
DISMISS_LABEL_SUGGESTION: 'OpenAI: Dismiss label suggestions',
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
88
app/javascript/dashboard/mixins/aiMixin.js
Normal file
88
app/javascript/dashboard/mixins/aiMixin.js
Normal file
@@ -0,0 +1,88 @@
|
||||
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' }),
|
||||
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;
|
||||
},
|
||||
},
|
||||
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
|
||||
},
|
||||
},
|
||||
};
|
||||
136
app/javascript/dashboard/mixins/specs/aiMixin.spec.js
Normal file
136
app/javascript/dashboard/mixins/specs/aiMixin.spec.js
Normal file
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user