diff --git a/app/javascript/dashboard/components/ui/Label.vue b/app/javascript/dashboard/components/ui/Label.vue
index 4b81f5111..84fe59ee4 100644
--- a/app/javascript/dashboard/components/ui/Label.vue
+++ b/app/javascript/dashboard/components/ui/Label.vue
@@ -4,7 +4,7 @@
@@ -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);
}
}
diff --git a/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue b/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue
index 7697cdd28..04255b5b0 100644
--- a/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue
+++ b/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue
@@ -70,16 +70,15 @@
+
+
diff --git a/app/javascript/dashboard/constants/localStorage.js b/app/javascript/dashboard/constants/localStorage.js
index f6ccc6458..44d90735e 100644
--- a/app/javascript/dashboard/constants/localStorage.js
+++ b/app/javascript/dashboard/constants/localStorage.js
@@ -3,4 +3,5 @@ export const LOCAL_STORAGE_KEYS = {
WIDGET_BUILDER: 'widgetBubble_',
DRAFT_MESSAGES: 'draftMessages',
COLOR_SCHEME: 'color_scheme',
+ DISMISSED_LABEL_SUGGESTIONS: 'dismissedLabelSuggestions',
};
diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js
index 6bc96c453..bec4df420 100644
--- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js
+++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js
@@ -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',
});
diff --git a/app/javascript/dashboard/i18n/locale/en/labelsMgmt.json b/app/javascript/dashboard/i18n/locale/en/labelsMgmt.json
index 2b6848a32..978592c83 100644
--- a/app/javascript/dashboard/i18n/locale/en/labelsMgmt.json
+++ b/app/javascript/dashboard/i18n/locale/en/labelsMgmt.json
@@ -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.",
diff --git a/app/javascript/dashboard/mixins/aiMixin.js b/app/javascript/dashboard/mixins/aiMixin.js
new file mode 100644
index 000000000..d30045705
--- /dev/null
+++ b/app/javascript/dashboard/mixins/aiMixin.js
@@ -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
+ },
+ },
+};
diff --git a/app/javascript/dashboard/mixins/specs/aiMixin.spec.js b/app/javascript/dashboard/mixins/specs/aiMixin.spec.js
new file mode 100644
index 000000000..a3200b6f3
--- /dev/null
+++ b/app/javascript/dashboard/mixins/specs/aiMixin.spec.js
@@ -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']);
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/conversations/index.js b/app/javascript/dashboard/store/modules/conversations/index.js
index 96f431cbe..6bce92e02 100644
--- a/app/javascript/dashboard/store/modules/conversations/index.js
+++ b/app/javascript/dashboard/store/modules/conversations/index.js
@@ -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 {
diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json
index c77b287fa..aba364d14 100644
--- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json
+++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json
@@ -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"
+ ]
}
diff --git a/app/javascript/shared/constants/busEvents.js b/app/javascript/shared/constants/busEvents.js
index 8ace54ef2..6456842e9 100644
--- a/app/javascript/shared/constants/busEvents.js
+++ b/app/javascript/shared/constants/busEvents.js
@@ -4,6 +4,7 @@ export const BUS_EVENTS = {
START_NEW_CONVERSATION: 'START_NEW_CONVERSATION',
FOCUS_CUSTOM_ATTRIBUTE: 'FOCUS_CUSTOM_ATTRIBUTE',
SCROLL_TO_MESSAGE: 'SCROLL_TO_MESSAGE',
+ FETCH_LABEL_SUGGESTIONS: 'FETCH_LABEL_SUGGESTIONS',
TOGGLE_SIDEMENU: 'TOGGLE_SIDEMENU',
ON_MESSAGE_LIST_SCROLL: 'ON_MESSAGE_LIST_SCROLL',
WEBSOCKET_DISCONNECT: 'WEBSOCKET_DISCONNECT',