feat: Rewrite aiMixin to a composable (#9955)
This PR will replace the usage of aiMixin with the useAI composable. Fixes https://linear.app/chatwoot/issue/CW-3443/rewrite-aimixin-mixin-to-a-composable Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
@@ -4,9 +4,9 @@ import { mapGetters } from 'vuex';
|
|||||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
import AICTAModal from './AICTAModal.vue';
|
import AICTAModal from './AICTAModal.vue';
|
||||||
import AIAssistanceModal from './AIAssistanceModal.vue';
|
import AIAssistanceModal from './AIAssistanceModal.vue';
|
||||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
|
||||||
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
||||||
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
|
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
|
||||||
|
|
||||||
@@ -16,17 +16,18 @@ export default {
|
|||||||
AICTAModal,
|
AICTAModal,
|
||||||
AIAssistanceCTAButton,
|
AIAssistanceCTAButton,
|
||||||
},
|
},
|
||||||
mixins: [aiMixin],
|
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const { uiSettings, updateUISettings } = useUISettings();
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
|
||||||
|
const { isAIIntegrationEnabled, draftMessage, recordAnalytics } = useAI();
|
||||||
|
|
||||||
const { isAdmin } = useAdmin();
|
const { isAdmin } = useAdmin();
|
||||||
|
|
||||||
const aiAssistanceButtonRef = ref(null);
|
const aiAssistanceButtonRef = ref(null);
|
||||||
const initialMessage = ref('');
|
const initialMessage = ref('');
|
||||||
|
|
||||||
const initializeMessage = draftMessage => {
|
const initializeMessage = draftMsg => {
|
||||||
initialMessage.value = draftMessage;
|
initialMessage.value = draftMsg;
|
||||||
};
|
};
|
||||||
const keyboardEvents = {
|
const keyboardEvents = {
|
||||||
'$mod+KeyZ': {
|
'$mod+KeyZ': {
|
||||||
@@ -48,6 +49,9 @@ export default {
|
|||||||
aiAssistanceButtonRef,
|
aiAssistanceButtonRef,
|
||||||
initialMessage,
|
initialMessage,
|
||||||
initializeMessage,
|
initializeMessage,
|
||||||
|
recordAnalytics,
|
||||||
|
isAIIntegrationEnabled,
|
||||||
|
draftMessage,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data: () => ({
|
data: () => ({
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||||
import AILoader from './AILoader.vue';
|
import AILoader from './AILoader.vue';
|
||||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
AILoader,
|
AILoader,
|
||||||
},
|
},
|
||||||
mixins: [aiMixin, messageFormatterMixin],
|
mixins: [messageFormatterMixin],
|
||||||
props: {
|
props: {
|
||||||
aiOption: {
|
aiOption: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
setup() {
|
||||||
|
const { draftMessage, processEvent, recordAnalytics } = useAI();
|
||||||
|
return {
|
||||||
|
draftMessage,
|
||||||
|
processEvent,
|
||||||
|
recordAnalytics,
|
||||||
|
};
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
generatedContent: '',
|
generatedContent: '',
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import { useVuelidate } from '@vuelidate/core';
|
|||||||
import { required } from '@vuelidate/validators';
|
import { required } from '@vuelidate/validators';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [aiMixin],
|
|
||||||
setup() {
|
setup() {
|
||||||
const { updateUISettings } = useUISettings();
|
const { updateUISettings } = useUISettings();
|
||||||
|
const { recordAnalytics } = useAI();
|
||||||
const v$ = useVuelidate();
|
const v$ = useVuelidate();
|
||||||
|
|
||||||
return { updateUISettings, v$ };
|
return { updateUISettings, v$, recordAnalytics };
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref } from 'vue';
|
|||||||
// composable
|
// composable
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
|
|
||||||
// components
|
// components
|
||||||
import ReplyBox from './ReplyBox.vue';
|
import ReplyBox from './ReplyBox.vue';
|
||||||
@@ -15,7 +16,6 @@ import { mapGetters } from 'vuex';
|
|||||||
|
|
||||||
// mixins
|
// mixins
|
||||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
|
||||||
|
|
||||||
// utils
|
// utils
|
||||||
import { getTypingUsersText } from '../../../helper/commons';
|
import { getTypingUsersText } from '../../../helper/commons';
|
||||||
@@ -40,7 +40,7 @@ export default {
|
|||||||
Banner,
|
Banner,
|
||||||
ConversationLabelSuggestion,
|
ConversationLabelSuggestion,
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin, aiMixin],
|
mixins: [inboxMixin],
|
||||||
props: {
|
props: {
|
||||||
isContactPanelOpen: {
|
isContactPanelOpen: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -72,12 +72,23 @@ export default {
|
|||||||
|
|
||||||
useKeyboardEvents(keyboardEvents, conversationFooterRef);
|
useKeyboardEvents(keyboardEvents, conversationFooterRef);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isAIIntegrationEnabled,
|
||||||
|
isLabelSuggestionFeatureEnabled,
|
||||||
|
fetchIntegrationsIfRequired,
|
||||||
|
fetchLabelSuggestions,
|
||||||
|
} = useAI();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isEnterprise,
|
isEnterprise,
|
||||||
conversationFooterRef,
|
conversationFooterRef,
|
||||||
isPopOutReplyBox,
|
isPopOutReplyBox,
|
||||||
closePopOutReplyBox,
|
closePopOutReplyBox,
|
||||||
showPopOutReplyBox,
|
showPopOutReplyBox,
|
||||||
|
isAIIntegrationEnabled,
|
||||||
|
isLabelSuggestionFeatureEnabled,
|
||||||
|
fetchIntegrationsIfRequired,
|
||||||
|
fetchLabelSuggestions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
// components
|
// components
|
||||||
import WootButton from '../../../ui/WootButton.vue';
|
import WootButton from '../../../ui/WootButton.vue';
|
||||||
import Avatar from '../../Avatar.vue';
|
import Avatar from '../../Avatar.vue';
|
||||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
|
||||||
|
// composables
|
||||||
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
|
|
||||||
// store & api
|
// store & api
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
@@ -18,7 +20,6 @@ export default {
|
|||||||
Avatar,
|
Avatar,
|
||||||
WootButton,
|
WootButton,
|
||||||
},
|
},
|
||||||
mixins: [aiMixin],
|
|
||||||
props: {
|
props: {
|
||||||
suggestedLabels: {
|
suggestedLabels: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -30,6 +31,11 @@ export default {
|
|||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
setup() {
|
||||||
|
const { isAIIntegrationEnabled } = useAI();
|
||||||
|
|
||||||
|
return { isAIIntegrationEnabled };
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isDismissed: false,
|
isDismissed: false,
|
||||||
@@ -41,7 +47,11 @@ export default {
|
|||||||
...mapGetters({
|
...mapGetters({
|
||||||
allLabels: 'labels/getLabels',
|
allLabels: 'labels/getLabels',
|
||||||
currentAccountId: 'getCurrentAccountId',
|
currentAccountId: 'getCurrentAccountId',
|
||||||
|
currentChat: 'getSelectedChat',
|
||||||
}),
|
}),
|
||||||
|
conversationId() {
|
||||||
|
return this.currentChat?.id;
|
||||||
|
},
|
||||||
labelTooltip() {
|
labelTooltip() {
|
||||||
if (this.preparedLabels.length > 1) {
|
if (this.preparedLabels.length > 1) {
|
||||||
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
|
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
|
||||||
|
|||||||
119
app/javascript/dashboard/composables/spec/useAI.spec.js
Normal file
119
app/javascript/dashboard/composables/spec/useAI.spec.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useAI } from '../useAI';
|
||||||
|
import {
|
||||||
|
useStore,
|
||||||
|
useStoreGetters,
|
||||||
|
useMapGetter,
|
||||||
|
} from 'dashboard/composables/store';
|
||||||
|
import { useAlert, useTrack } from 'dashboard/composables';
|
||||||
|
import { useI18n } from '../useI18n';
|
||||||
|
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/store');
|
||||||
|
vi.mock('dashboard/composables');
|
||||||
|
vi.mock('../useI18n');
|
||||||
|
vi.mock('dashboard/api/integrations/openapi');
|
||||||
|
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
|
||||||
|
OPEN_AI_EVENTS: {
|
||||||
|
TEST_EVENT: 'open_ai_test_event',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useAI', () => {
|
||||||
|
const mockStore = {
|
||||||
|
dispatch: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockGetters = {
|
||||||
|
'integrations/getUIFlags': { value: { isFetching: false } },
|
||||||
|
'draftMessages/get': { value: () => 'Draft message' },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useStore.mockReturnValue(mockStore);
|
||||||
|
useStoreGetters.mockReturnValue(mockGetters);
|
||||||
|
useMapGetter.mockImplementation(getter => {
|
||||||
|
const mockValues = {
|
||||||
|
'integrations/getAppIntegrations': [],
|
||||||
|
getSelectedChat: { id: '123' },
|
||||||
|
'draftMessages/getReplyEditorMode': 'reply',
|
||||||
|
};
|
||||||
|
return { value: mockValues[getter] };
|
||||||
|
});
|
||||||
|
useTrack.mockReturnValue(vi.fn());
|
||||||
|
useI18n.mockReturnValue({ t: vi.fn() });
|
||||||
|
useAlert.mockReturnValue(vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes computed properties correctly', async () => {
|
||||||
|
const { uiFlags, appIntegrations, currentChat, replyMode, draftMessage } =
|
||||||
|
useAI();
|
||||||
|
|
||||||
|
expect(uiFlags.value).toEqual({ isFetching: false });
|
||||||
|
expect(appIntegrations.value).toEqual([]);
|
||||||
|
expect(currentChat.value).toEqual({ id: '123' });
|
||||||
|
expect(replyMode.value).toBe('reply');
|
||||||
|
expect(draftMessage.value).toBe('Draft message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches integrations if required', async () => {
|
||||||
|
const { fetchIntegrationsIfRequired } = useAI();
|
||||||
|
await fetchIntegrationsIfRequired();
|
||||||
|
expect(mockStore.dispatch).toHaveBeenCalledWith('integrations/get');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch integrations if already loaded', async () => {
|
||||||
|
useMapGetter.mockImplementation(getter => {
|
||||||
|
const mockValues = {
|
||||||
|
'integrations/getAppIntegrations': [{ id: 'openai' }],
|
||||||
|
getSelectedChat: { id: '123' },
|
||||||
|
'draftMessages/getReplyEditorMode': 'reply',
|
||||||
|
};
|
||||||
|
return { value: mockValues[getter] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fetchIntegrationsIfRequired } = useAI();
|
||||||
|
await fetchIntegrationsIfRequired();
|
||||||
|
expect(mockStore.dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records analytics correctly', async () => {
|
||||||
|
const mockTrack = vi.fn();
|
||||||
|
useTrack.mockReturnValue(mockTrack);
|
||||||
|
const { recordAnalytics } = useAI();
|
||||||
|
|
||||||
|
await recordAnalytics('TEST_EVENT', { data: 'test' });
|
||||||
|
|
||||||
|
expect(mockTrack).toHaveBeenCalledWith('open_ai_test_event', {
|
||||||
|
type: 'TEST_EVENT',
|
||||||
|
data: 'test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches label suggestions', async () => {
|
||||||
|
OpenAPI.processEvent.mockResolvedValue({
|
||||||
|
data: { message: 'label1, label2' },
|
||||||
|
});
|
||||||
|
|
||||||
|
useMapGetter.mockImplementation(getter => {
|
||||||
|
const mockValues = {
|
||||||
|
'integrations/getAppIntegrations': [
|
||||||
|
{ id: 'openai', hooks: [{ id: 'hook1' }] },
|
||||||
|
],
|
||||||
|
getSelectedChat: { id: '123' },
|
||||||
|
};
|
||||||
|
return { value: mockValues[getter] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fetchLabelSuggestions } = useAI();
|
||||||
|
const result = await fetchLabelSuggestions();
|
||||||
|
|
||||||
|
expect(OpenAPI.processEvent).toHaveBeenCalledWith({
|
||||||
|
type: 'label_suggestion',
|
||||||
|
hookId: 'hook1',
|
||||||
|
conversationId: '123',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(['label1', 'label2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
204
app/javascript/dashboard/composables/useAI.js
Normal file
204
app/javascript/dashboard/composables/useAI.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import {
|
||||||
|
useStore,
|
||||||
|
useStoreGetters,
|
||||||
|
useMapGetter,
|
||||||
|
} from 'dashboard/composables/store';
|
||||||
|
import { useAlert, useTrack } from 'dashboard/composables';
|
||||||
|
import { useI18n } from './useI18n';
|
||||||
|
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans and normalizes a list of labels.
|
||||||
|
* @param {string} labels - A comma-separated string of labels.
|
||||||
|
* @returns {string[]} An array of cleaned and unique labels.
|
||||||
|
*/
|
||||||
|
const 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composable function for AI-related operations in the dashboard.
|
||||||
|
* @returns {Object} An object containing AI-related methods and computed properties.
|
||||||
|
*/
|
||||||
|
export function useAI() {
|
||||||
|
const store = useStore();
|
||||||
|
const getters = useStoreGetters();
|
||||||
|
const track = useTrack();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for UI flags.
|
||||||
|
* @type {import('vue').ComputedRef<Object>}
|
||||||
|
*/
|
||||||
|
const uiFlags = computed(() => getters['integrations/getUIFlags'].value);
|
||||||
|
|
||||||
|
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
|
||||||
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
|
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for the AI integration.
|
||||||
|
* @type {import('vue').ComputedRef<Object|undefined>}
|
||||||
|
*/
|
||||||
|
const aiIntegration = computed(
|
||||||
|
() =>
|
||||||
|
appIntegrations.value.find(
|
||||||
|
integration => integration.id === 'openai' && !!integration.hooks.length
|
||||||
|
)?.hooks[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property to check if AI integration is enabled.
|
||||||
|
* @type {import('vue').ComputedRef<boolean>}
|
||||||
|
*/
|
||||||
|
const isAIIntegrationEnabled = computed(() => !!aiIntegration.value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property to check if label suggestion feature is enabled.
|
||||||
|
* @type {import('vue').ComputedRef<boolean>}
|
||||||
|
*/
|
||||||
|
const isLabelSuggestionFeatureEnabled = computed(() => {
|
||||||
|
if (aiIntegration.value) {
|
||||||
|
const { settings = {} } = aiIntegration.value || {};
|
||||||
|
return settings.label_suggestion;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property to check if app integrations are being fetched.
|
||||||
|
* @type {import('vue').ComputedRef<boolean>}
|
||||||
|
*/
|
||||||
|
const isFetchingAppIntegrations = computed(() => uiFlags.value.isFetching);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for the hook ID.
|
||||||
|
* @type {import('vue').ComputedRef<string|undefined>}
|
||||||
|
*/
|
||||||
|
const hookId = computed(() => aiIntegration.value?.id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for the conversation ID.
|
||||||
|
* @type {import('vue').ComputedRef<string|undefined>}
|
||||||
|
*/
|
||||||
|
const conversationId = computed(() => currentChat.value?.id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for the draft key.
|
||||||
|
* @type {import('vue').ComputedRef<string>}
|
||||||
|
*/
|
||||||
|
const draftKey = computed(
|
||||||
|
() => `draft-${conversationId.value}-${replyMode.value}`
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for the draft message.
|
||||||
|
* @type {import('vue').ComputedRef<string>}
|
||||||
|
*/
|
||||||
|
const draftMessage = computed(() =>
|
||||||
|
getters['draftMessages/get'].value(draftKey.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches integrations if they haven't been loaded yet.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const fetchIntegrationsIfRequired = async () => {
|
||||||
|
if (!appIntegrations.value.length) {
|
||||||
|
await store.dispatch('integrations/get');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records analytics for AI-related events.
|
||||||
|
* @param {string} type - The type of event.
|
||||||
|
* @param {Object} payload - Additional data for the event.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const recordAnalytics = async (type, payload) => {
|
||||||
|
const event = OPEN_AI_EVENTS[type.toUpperCase()];
|
||||||
|
if (event) {
|
||||||
|
track(event, {
|
||||||
|
type,
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches label suggestions for the current conversation.
|
||||||
|
* @returns {Promise<string[]>} An array of suggested labels.
|
||||||
|
*/
|
||||||
|
const fetchLabelSuggestions = async () => {
|
||||||
|
if (!conversationId.value) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await OpenAPI.processEvent({
|
||||||
|
type: 'label_suggestion',
|
||||||
|
hookId: hookId.value,
|
||||||
|
conversationId: conversationId.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { message: labels },
|
||||||
|
} = result;
|
||||||
|
|
||||||
|
return cleanLabels(labels);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes an AI event, such as rephrasing content.
|
||||||
|
* @param {string} [type='rephrase'] - The type of AI event to process.
|
||||||
|
* @returns {Promise<string>} The generated message or an empty string if an error occurs.
|
||||||
|
*/
|
||||||
|
const processEvent = async (type = 'rephrase') => {
|
||||||
|
try {
|
||||||
|
const result = await OpenAPI.processEvent({
|
||||||
|
hookId: hookId.value,
|
||||||
|
type,
|
||||||
|
content: draftMessage.value,
|
||||||
|
conversationId: conversationId.value,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
data: { message: generatedMessage },
|
||||||
|
} = result;
|
||||||
|
return generatedMessage;
|
||||||
|
} catch (error) {
|
||||||
|
const errorData = error.response.data.error;
|
||||||
|
const errorMessage =
|
||||||
|
errorData?.error?.message ||
|
||||||
|
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
|
||||||
|
useAlert(errorMessage);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchIntegrationsIfRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
draftMessage,
|
||||||
|
uiFlags,
|
||||||
|
appIntegrations,
|
||||||
|
currentChat,
|
||||||
|
replyMode,
|
||||||
|
isAIIntegrationEnabled,
|
||||||
|
isLabelSuggestionFeatureEnabled,
|
||||||
|
isFetchingAppIntegrations,
|
||||||
|
fetchIntegrationsIfRequired,
|
||||||
|
recordAnalytics,
|
||||||
|
fetchLabelSuggestions,
|
||||||
|
processEvent,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { mapGetters } from 'vuex';
|
|
||||||
import { useAlert } from 'dashboard/composables';
|
|
||||||
import { OPEN_AI_EVENTS } from '../helper/AnalyticsHelper/events';
|
|
||||||
import OpenAPI from '../api/integrations/openapi';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
mounted() {
|
|
||||||
this.fetchIntegrationsIfRequired();
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapGetters({
|
|
||||||
uiFlags: 'integrations/getUIFlags',
|
|
||||||
appIntegrations: 'integrations/getAppIntegrations',
|
|
||||||
currentChat: 'getSelectedChat',
|
|
||||||
replyMode: 'draftMessages/getReplyEditorMode',
|
|
||||||
}),
|
|
||||||
aiIntegration() {
|
|
||||||
return this.appIntegrations.find(
|
|
||||||
integration => integration.id === 'openai' && !!integration.hooks.length
|
|
||||||
)?.hooks[0];
|
|
||||||
},
|
|
||||||
isAIIntegrationEnabled() {
|
|
||||||
return !!this.aiIntegration;
|
|
||||||
},
|
|
||||||
isLabelSuggestionFeatureEnabled() {
|
|
||||||
if (this.aiIntegration) {
|
|
||||||
const { settings = {} } = this.aiIntegration || {};
|
|
||||||
return settings.label_suggestion;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
isFetchingAppIntegrations() {
|
|
||||||
return this.uiFlags.isFetching;
|
|
||||||
},
|
|
||||||
hookId() {
|
|
||||||
return this.aiIntegration.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 }) {
|
|
||||||
if (!conversationId) return [];
|
|
||||||
|
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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) {
|
|
||||||
const errorData = error.response.data.error;
|
|
||||||
const errorMessage =
|
|
||||||
errorData?.error?.message ||
|
|
||||||
this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
|
|
||||||
useAlert(errorMessage);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
vi.mock('../../api/integrations/openapi');
|
|
||||||
vi.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 = vi.fn();
|
|
||||||
LocalStorage.set = vi.fn();
|
|
||||||
LocalStorage.get = vi.fn();
|
|
||||||
|
|
||||||
actions = {
|
|
||||||
['integrations/get']: vi.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 = vi.spyOn(wrapper.vm.$store, 'dispatch');
|
|
||||||
await wrapper.vm.fetchIntegrationsIfRequired();
|
|
||||||
expect(dispatchSpy).toHaveBeenCalledWith('integrations/get');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not fetch integrations', async () => {
|
|
||||||
const dispatchSpy = vi.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 = vi.spyOn(OpenAPI, 'processEvent');
|
|
||||||
await wrapper.vm.fetchLabelSuggestions({
|
|
||||||
conversationId: '123',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(processEventSpy).toHaveBeenCalledWith({
|
|
||||||
type: 'label_suggestion',
|
|
||||||
hookId: 'hook1',
|
|
||||||
conversationId: '123',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cleans labels', () => {
|
|
||||||
const labels = 'label1, label2, label1';
|
|
||||||
expect(wrapper.vm.cleanLabels(labels)).toEqual(['label1', 'label2']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import '@chatwoot/ninja-keys';
|
import '@chatwoot/ninja-keys';
|
||||||
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
|
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
|
||||||
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
import { useAgentsList } from 'dashboard/composables/useAgentsList';
|
import { useAgentsList } from 'dashboard/composables/useAgentsList';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import conversationHotKeysMixin from './conversationHotKeys';
|
import conversationHotKeysMixin from './conversationHotKeys';
|
||||||
@@ -27,6 +28,7 @@ export default {
|
|||||||
removeLabelFromConversation,
|
removeLabelFromConversation,
|
||||||
} = useConversationLabels();
|
} = useConversationLabels();
|
||||||
|
|
||||||
|
const { isAIIntegrationEnabled } = useAI();
|
||||||
const { agentsList, assignableAgents } = useAgentsList();
|
const { agentsList, assignableAgents } = useAgentsList();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -36,6 +38,7 @@ export default {
|
|||||||
inactiveLabels,
|
inactiveLabels,
|
||||||
addLabelToConversation,
|
addLabelToConversation,
|
||||||
removeLabelFromConversation,
|
removeLabelFromConversation,
|
||||||
|
isAIIntegrationEnabled,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { emitter } from 'shared/helpers/mitt';
|
|||||||
|
|
||||||
import { CMD_AI_ASSIST } from './commandBarBusEvents';
|
import { CMD_AI_ASSIST } from './commandBarBusEvents';
|
||||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
|
||||||
import {
|
import {
|
||||||
ICON_ADD_LABEL,
|
ICON_ADD_LABEL,
|
||||||
ICON_ASSIGN_AGENT,
|
ICON_ASSIGN_AGENT,
|
||||||
@@ -36,7 +35,6 @@ import {
|
|||||||
isAInboxViewRoute,
|
isAInboxViewRoute,
|
||||||
} from '../../../helper/routeHelpers';
|
} from '../../../helper/routeHelpers';
|
||||||
export default {
|
export default {
|
||||||
mixins: [aiMixin],
|
|
||||||
watch: {
|
watch: {
|
||||||
assignableAgents() {
|
assignableAgents() {
|
||||||
this.setCommandbarData();
|
this.setCommandbarData();
|
||||||
|
|||||||
Reference in New Issue
Block a user