mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 12:08:01 +00:00
feat: label suggestion UI (#7480)
This commit is contained in:
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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user