Merge branch 'develop' into feat/ui-lib
This commit is contained in:
@@ -3,10 +3,13 @@ import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
export function useBulkActions() {
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const selectedConversations = useMapGetter(
|
||||
'bulkActions/getSelectedConversationIds'
|
||||
@@ -116,17 +119,61 @@ export function useBulkActions() {
|
||||
}
|
||||
|
||||
async function onUpdateConversations(status, snoozedUntil) {
|
||||
try {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: selectedConversations.value,
|
||||
fields: {
|
||||
status,
|
||||
let conversationIds = selectedConversations.value;
|
||||
let skippedCount = 0;
|
||||
|
||||
// If resolving, check for required attributes
|
||||
if (status === wootConstants.STATUS_TYPE.RESOLVED) {
|
||||
const { validIds, skippedIds } = selectedConversations.value.reduce(
|
||||
(acc, id) => {
|
||||
const conversation = store.getters.getConversationById(id);
|
||||
const currentCustomAttributes = conversation?.custom_attributes || {};
|
||||
const { hasMissing } = checkMissingAttributes(
|
||||
currentCustomAttributes
|
||||
);
|
||||
|
||||
if (!hasMissing) {
|
||||
acc.validIds.push(id);
|
||||
} else {
|
||||
acc.skippedIds.push(id);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
snoozed_until: snoozedUntil,
|
||||
});
|
||||
{ validIds: [], skippedIds: [] }
|
||||
);
|
||||
|
||||
conversationIds = validIds;
|
||||
skippedCount = skippedIds.length;
|
||||
|
||||
if (skippedCount > 0 && validIds.length === 0) {
|
||||
// All conversations have missing attributes
|
||||
useAlert(
|
||||
t('BULK_ACTION.RESOLVE.ALL_MISSING_ATTRIBUTES') ||
|
||||
'Cannot resolve conversations due to missing required attributes'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (conversationIds.length > 0) {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: conversationIds,
|
||||
fields: {
|
||||
status,
|
||||
},
|
||||
snoozed_until: snoozedUntil,
|
||||
});
|
||||
}
|
||||
|
||||
store.dispatch('bulkActions/clearSelectedConversationIds');
|
||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
|
||||
|
||||
if (skippedCount > 0) {
|
||||
useAlert(t('BULK_ACTION.RESOLVE.PARTIAL_SUCCESS'));
|
||||
} else {
|
||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
|
||||
}
|
||||
} catch (err) {
|
||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useAgentsList } from 'dashboard/composables/useAgentsList';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import {
|
||||
@@ -18,7 +18,7 @@ vi.mock('dashboard/composables/store');
|
||||
vi.mock('vue-i18n');
|
||||
vi.mock('vue-router');
|
||||
vi.mock('dashboard/composables/useConversationLabels');
|
||||
vi.mock('dashboard/composables/useAI');
|
||||
vi.mock('dashboard/composables/useCaptain');
|
||||
vi.mock('dashboard/composables/useAgentsList');
|
||||
|
||||
describe('useConversationHotKeys', () => {
|
||||
@@ -49,7 +49,7 @@ describe('useConversationHotKeys', () => {
|
||||
addLabelToConversation: vi.fn(),
|
||||
removeLabelFromConversation: vi.fn(),
|
||||
});
|
||||
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: true } });
|
||||
useCaptain.mockReturnValue({ captainTasksEnabled: { value: true } });
|
||||
useAgentsList.mockReturnValue({
|
||||
agentsList: { value: [] },
|
||||
assignableAgents: { value: mockAssignableAgents },
|
||||
@@ -67,7 +67,7 @@ describe('useConversationHotKeys', () => {
|
||||
expect(conversationHotKeys.value.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include AI assist actions when AI integration is enabled', () => {
|
||||
it('should include AI assist actions when captain tasks is enabled', () => {
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
const aiAssistAction = conversationHotKeys.value.find(
|
||||
action => action.id === 'ai_assist'
|
||||
@@ -75,8 +75,8 @@ describe('useConversationHotKeys', () => {
|
||||
expect(aiAssistAction).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not include AI assist actions when AI integration is disabled', () => {
|
||||
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: false } });
|
||||
it('should not include AI assist actions when captain tasks is disabled', () => {
|
||||
useCaptain.mockReturnValue({ captainTasksEnabled: { value: false } });
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
const aiAssistAction = conversationHotKeys.value.find(
|
||||
action => action.id === 'ai_assist'
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useAgentsList } from 'dashboard/composables/useAgentsList';
|
||||
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
@@ -102,8 +102,8 @@ const createNonDraftMessageAIAssistActions = (t, replyMode) => {
|
||||
const createDraftMessageAIAssistActions = t => {
|
||||
return [
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
|
||||
key: 'rephrase',
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CONFIDENT'),
|
||||
key: 'confident',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
{
|
||||
@@ -112,28 +112,23 @@ const createDraftMessageAIAssistActions = t => {
|
||||
icon: ICON_AI_GRAMMAR,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
|
||||
key: 'expand',
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.PROFESSIONAL'),
|
||||
key: 'professional',
|
||||
icon: ICON_AI_EXPAND,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
|
||||
key: 'shorten',
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CASUAL'),
|
||||
key: 'casual',
|
||||
icon: ICON_AI_SHORTEN,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
|
||||
key: 'make_friendly',
|
||||
key: 'friendly',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FORMAL'),
|
||||
key: 'make_formal',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
|
||||
key: 'simplify',
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.STRAIGHTFORWARD'),
|
||||
key: 'straightforward',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
];
|
||||
@@ -151,7 +146,7 @@ export function useConversationHotKeys() {
|
||||
removeLabelFromConversation,
|
||||
} = useConversationLabels();
|
||||
|
||||
const { isAIIntegrationEnabled } = useAI();
|
||||
const { captainTasksEnabled } = useCaptain();
|
||||
const { agentsList } = useAgentsList();
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
@@ -386,7 +381,7 @@ export function useConversationHotKeys() {
|
||||
...labelActions.value,
|
||||
...assignPriorityActions.value,
|
||||
];
|
||||
if (isAIIntegrationEnabled.value) {
|
||||
if (captainTasksEnabled.value) {
|
||||
return [...defaultConversationHotKeys, ...AIAssistActions.value];
|
||||
}
|
||||
return defaultConversationHotKeys;
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useAI } from '../useAI';
|
||||
import {
|
||||
useStore,
|
||||
useStoreGetters,
|
||||
useMapGetter,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('vue-i18n');
|
||||
vi.mock('dashboard/api/integrations/openapi');
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
actual.default = {
|
||||
track: vi.fn(),
|
||||
};
|
||||
return actual;
|
||||
});
|
||||
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] };
|
||||
});
|
||||
useI18n.mockReturnValue({ t: 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 = analyticsHelper.track;
|
||||
const { recordAnalytics } = useAI();
|
||||
|
||||
await recordAnalytics('TEST_EVENT', { data: 'test' });
|
||||
|
||||
expect(analyticsHelper.track).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']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useCaptain } from '../useCaptain';
|
||||
import {
|
||||
useFunctionGetter,
|
||||
useMapGetter,
|
||||
useStore,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables/useAccount');
|
||||
vi.mock('dashboard/composables/useConfig');
|
||||
vi.mock('vue-i18n');
|
||||
vi.mock('dashboard/api/captain/tasks');
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
actual.default = {
|
||||
track: vi.fn(),
|
||||
};
|
||||
return actual;
|
||||
});
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
|
||||
CAPTAIN_EVENTS: {
|
||||
TEST_EVENT: 'captain_test_event',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useCaptain', () => {
|
||||
const mockStore = {
|
||||
dispatch: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useStore.mockReturnValue(mockStore);
|
||||
useFunctionGetter.mockReturnValue({ value: 'Draft message' });
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const mockValues = {
|
||||
'accounts/getUIFlags': { isFetchingLimits: false },
|
||||
getSelectedChat: { id: '123' },
|
||||
'draftMessages/getReplyEditorMode': 'reply',
|
||||
};
|
||||
return { value: mockValues[getter] };
|
||||
});
|
||||
useI18n.mockReturnValue({ t: vi.fn() });
|
||||
useAccount.mockReturnValue({
|
||||
isCloudFeatureEnabled: vi.fn().mockReturnValue(true),
|
||||
currentAccount: { value: { limits: { captain: {} } } },
|
||||
});
|
||||
useConfig.mockReturnValue({
|
||||
isEnterprise: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes computed properties correctly', async () => {
|
||||
const { captainEnabled, captainTasksEnabled, currentChat, draftMessage } =
|
||||
useCaptain();
|
||||
|
||||
expect(captainEnabled.value).toBe(true);
|
||||
expect(captainTasksEnabled.value).toBe(true);
|
||||
expect(currentChat.value).toEqual({ id: '123' });
|
||||
expect(draftMessage.value).toBe('Draft message');
|
||||
});
|
||||
|
||||
it('rewrites content', async () => {
|
||||
TasksAPI.rewrite.mockResolvedValue({
|
||||
data: { message: 'Rewritten content', follow_up_context: { id: 'ctx1' } },
|
||||
});
|
||||
|
||||
const { rewriteContent } = useCaptain();
|
||||
const result = await rewriteContent('Original content', 'improve', {});
|
||||
|
||||
expect(TasksAPI.rewrite).toHaveBeenCalledWith(
|
||||
{
|
||||
content: 'Original content',
|
||||
operation: 'improve',
|
||||
conversationId: '123',
|
||||
},
|
||||
undefined
|
||||
);
|
||||
expect(result).toEqual({
|
||||
message: 'Rewritten content',
|
||||
followUpContext: { id: 'ctx1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('summarizes conversation', async () => {
|
||||
TasksAPI.summarize.mockResolvedValue({
|
||||
data: { message: 'Summary', follow_up_context: { id: 'ctx2' } },
|
||||
});
|
||||
|
||||
const { summarizeConversation } = useCaptain();
|
||||
const result = await summarizeConversation({});
|
||||
|
||||
expect(TasksAPI.summarize).toHaveBeenCalledWith('123', undefined);
|
||||
expect(result).toEqual({
|
||||
message: 'Summary',
|
||||
followUpContext: { id: 'ctx2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('gets reply suggestion', async () => {
|
||||
TasksAPI.replySuggestion.mockResolvedValue({
|
||||
data: { message: 'Reply suggestion', follow_up_context: { id: 'ctx3' } },
|
||||
});
|
||||
|
||||
const { getReplySuggestion } = useCaptain();
|
||||
const result = await getReplySuggestion({});
|
||||
|
||||
expect(TasksAPI.replySuggestion).toHaveBeenCalledWith('123', undefined);
|
||||
expect(result).toEqual({
|
||||
message: 'Reply suggestion',
|
||||
followUpContext: { id: 'ctx3' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends follow-up message', async () => {
|
||||
TasksAPI.followUp.mockResolvedValue({
|
||||
data: {
|
||||
message: 'Follow-up response',
|
||||
follow_up_context: { id: 'ctx4' },
|
||||
},
|
||||
});
|
||||
|
||||
const { followUp } = useCaptain();
|
||||
const result = await followUp({
|
||||
followUpContext: { id: 'ctx3' },
|
||||
message: 'Make it shorter',
|
||||
});
|
||||
|
||||
expect(TasksAPI.followUp).toHaveBeenCalledWith(
|
||||
{
|
||||
followUpContext: { id: 'ctx3' },
|
||||
message: 'Make it shorter',
|
||||
conversationId: '123',
|
||||
},
|
||||
undefined
|
||||
);
|
||||
expect(result).toEqual({
|
||||
message: 'Follow-up response',
|
||||
followUpContext: { id: 'ctx4' },
|
||||
});
|
||||
});
|
||||
|
||||
it('processes event and routes to correct method', async () => {
|
||||
TasksAPI.summarize.mockResolvedValue({
|
||||
data: { message: 'Summary' },
|
||||
});
|
||||
TasksAPI.replySuggestion.mockResolvedValue({
|
||||
data: { message: 'Reply' },
|
||||
});
|
||||
TasksAPI.rewrite.mockResolvedValue({
|
||||
data: { message: 'Rewritten' },
|
||||
});
|
||||
|
||||
const { processEvent } = useCaptain();
|
||||
|
||||
// Test summarize
|
||||
await processEvent('summarize', '', {});
|
||||
expect(TasksAPI.summarize).toHaveBeenCalled();
|
||||
|
||||
// Test reply_suggestion
|
||||
await processEvent('reply_suggestion', '', {});
|
||||
expect(TasksAPI.replySuggestion).toHaveBeenCalled();
|
||||
|
||||
// Test rewrite (improve)
|
||||
await processEvent('improve', 'content', {});
|
||||
expect(TasksAPI.rewrite).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useConversationRequiredAttributes } from '../useConversationRequiredAttributes';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables/useAccount');
|
||||
|
||||
const defaultAttributes = [
|
||||
{
|
||||
attributeKey: 'priority',
|
||||
attributeDisplayName: 'Priority',
|
||||
attributeDisplayType: 'list',
|
||||
attributeValues: ['High', 'Medium', 'Low'],
|
||||
},
|
||||
{
|
||||
attributeKey: 'category',
|
||||
attributeDisplayName: 'Category',
|
||||
attributeDisplayType: 'text',
|
||||
attributeValues: [],
|
||||
},
|
||||
{
|
||||
attributeKey: 'is_urgent',
|
||||
attributeDisplayName: 'Is Urgent',
|
||||
attributeDisplayType: 'checkbox',
|
||||
attributeValues: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe('useConversationRequiredAttributes', () => {
|
||||
beforeEach(() => {
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
if (getter === 'accounts/isFeatureEnabledonAccount') {
|
||||
return { value: () => true };
|
||||
}
|
||||
if (getter === 'attributes/getConversationAttributes') {
|
||||
return { value: defaultAttributes };
|
||||
}
|
||||
return { value: null };
|
||||
});
|
||||
|
||||
useAccount.mockReturnValue({
|
||||
currentAccount: {
|
||||
value: {
|
||||
settings: {
|
||||
conversation_required_attributes: [
|
||||
'priority',
|
||||
'category',
|
||||
'is_urgent',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: { value: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
const setupMocks = (
|
||||
requiredAttributes = ['priority', 'category', 'is_urgent'],
|
||||
{ attributes = defaultAttributes, featureEnabled = true } = {}
|
||||
) => {
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
if (getter === 'accounts/isFeatureEnabledonAccount') {
|
||||
return { value: () => featureEnabled };
|
||||
}
|
||||
if (getter === 'attributes/getConversationAttributes') {
|
||||
return { value: attributes };
|
||||
}
|
||||
return { value: null };
|
||||
});
|
||||
|
||||
useAccount.mockReturnValue({
|
||||
currentAccount: {
|
||||
value: {
|
||||
settings: {
|
||||
conversation_required_attributes: requiredAttributes,
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: { value: 1 },
|
||||
});
|
||||
};
|
||||
|
||||
describe('requiredAttributeKeys', () => {
|
||||
it('should return required attribute keys from account settings', () => {
|
||||
setupMocks();
|
||||
const { requiredAttributeKeys } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributeKeys.value).toEqual([
|
||||
'priority',
|
||||
'category',
|
||||
'is_urgent',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array when no required attributes configured', () => {
|
||||
setupMocks([]);
|
||||
const { requiredAttributeKeys } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributeKeys.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when account settings is null', () => {
|
||||
setupMocks([], { attributes: [] });
|
||||
useAccount.mockReturnValue({
|
||||
currentAccount: { value: { settings: null } },
|
||||
accountId: { value: 1 },
|
||||
});
|
||||
|
||||
const { requiredAttributeKeys } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributeKeys.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requiredAttributes', () => {
|
||||
it('should return full attribute definitions for required attributes only', () => {
|
||||
setupMocks();
|
||||
const { requiredAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributes.value).toHaveLength(3);
|
||||
expect(requiredAttributes.value[0]).toEqual({
|
||||
attributeKey: 'priority',
|
||||
attributeDisplayName: 'Priority',
|
||||
attributeDisplayType: 'list',
|
||||
attributeValues: ['High', 'Medium', 'Low'],
|
||||
value: 'priority',
|
||||
label: 'Priority',
|
||||
type: 'list',
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out deleted attributes that no longer exist', () => {
|
||||
// Mock with only 2 attributes available but 3 required
|
||||
setupMocks(['priority', 'category', 'is_urgent'], {
|
||||
attributes: [
|
||||
{
|
||||
attributeKey: 'priority',
|
||||
attributeDisplayName: 'Priority',
|
||||
attributeDisplayType: 'list',
|
||||
attributeValues: ['High', 'Medium', 'Low'],
|
||||
},
|
||||
{
|
||||
attributeKey: 'is_urgent',
|
||||
attributeDisplayName: 'Is Urgent',
|
||||
attributeDisplayType: 'checkbox',
|
||||
attributeValues: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { requiredAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributes.value).toHaveLength(2);
|
||||
expect(requiredAttributes.value.map(attr => attr.value)).toEqual([
|
||||
'priority',
|
||||
'is_urgent',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkMissingAttributes', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it('should return no missing when all attributes are filled', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
is_urgent: true,
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
expect(result.all).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should detect missing text attributes', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
is_urgent: true,
|
||||
// category is missing
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing).toHaveLength(1);
|
||||
expect(result.missing[0].value).toBe('category');
|
||||
});
|
||||
|
||||
it('should detect empty string values as missing', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: '', // empty string
|
||||
is_urgent: true,
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing[0].value).toBe('category');
|
||||
});
|
||||
|
||||
it('should consider checkbox attribute present when value is true', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
is_urgent: true,
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should consider checkbox attribute present when value is false', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
is_urgent: false, // false is still considered "filled"
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect missing checkbox when key does not exist', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
// is_urgent key is completely missing
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing).toHaveLength(1);
|
||||
expect(result.missing[0].value).toBe('is_urgent');
|
||||
expect(result.missing[0].type).toBe('checkbox');
|
||||
});
|
||||
|
||||
it('should handle falsy values correctly for non-checkbox attributes', () => {
|
||||
setupMocks(['score', 'status_flag'], {
|
||||
attributes: [
|
||||
{
|
||||
attributeKey: 'score',
|
||||
attributeDisplayName: 'Score',
|
||||
attributeDisplayType: 'number',
|
||||
attributeValues: [],
|
||||
},
|
||||
{
|
||||
attributeKey: 'status_flag',
|
||||
attributeDisplayName: 'Status Flag',
|
||||
attributeDisplayType: 'text',
|
||||
attributeValues: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
score: 0, // zero should be considered valid, not missing
|
||||
status_flag: false, // false should be considered valid, not missing
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle null values as missing for text attributes', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: null, // null should be missing for text attribute
|
||||
is_urgent: true, // checkbox is present
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing).toHaveLength(1);
|
||||
expect(result.missing[0].value).toBe('category');
|
||||
});
|
||||
|
||||
it('should consider undefined checkbox values as present when key exists', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
is_urgent: undefined, // key exists but value is undefined - still considered "filled" for checkbox
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return no missing when no attributes are required', () => {
|
||||
setupMocks([]); // No required attributes
|
||||
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const result = checkMissingAttributes({});
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle whitespace-only values as missing', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: ' ', // whitespace only
|
||||
is_urgent: true,
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing[0].value).toBe('category');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +1,31 @@
|
||||
import { ref } from 'vue';
|
||||
import { useTranslations } from '../useTranslations';
|
||||
import { selectTranslation } from '../useTranslations';
|
||||
|
||||
describe('useTranslations', () => {
|
||||
it('returns false and null when contentAttributes is null', () => {
|
||||
const contentAttributes = ref(null);
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
describe('selectTranslation', () => {
|
||||
it('returns null when translations is null', () => {
|
||||
expect(selectTranslation(null, 'en', 'en')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns false and null when translations are missing', () => {
|
||||
const contentAttributes = ref({});
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
it('returns null when translations is empty', () => {
|
||||
expect(selectTranslation({}, 'en', 'en')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns false and null when translations is an empty object', () => {
|
||||
const contentAttributes = ref({ translations: {} });
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
it('returns first translation when no locale matches', () => {
|
||||
const translations = { en: 'Hello', es: 'Hola' };
|
||||
expect(selectTranslation(translations, 'fr', 'de')).toBe('Hello');
|
||||
});
|
||||
|
||||
it('returns true and correct translation content when translations exist', () => {
|
||||
const contentAttributes = ref({
|
||||
translations: { en: 'Hello' },
|
||||
});
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(true);
|
||||
// Should return the first translation (en: 'Hello')
|
||||
expect(translationContent.value).toBe('Hello');
|
||||
it('returns translation matching agent locale', () => {
|
||||
const translations = { en: 'Hello', es: 'Hola', zh_CN: '你好' };
|
||||
expect(selectTranslation(translations, 'es', 'en')).toBe('Hola');
|
||||
});
|
||||
|
||||
it('falls back to account locale when agent locale not found', () => {
|
||||
const translations = { en: 'Hello', zh_CN: '你好' };
|
||||
expect(selectTranslation(translations, 'fr', 'zh_CN')).toBe('你好');
|
||||
});
|
||||
|
||||
it('returns first translation when both locales are undefined', () => {
|
||||
const translations = { en: 'Hello', es: 'Hola' };
|
||||
expect(selectTranslation(translations, undefined, undefined)).toBe('Hello');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { computed, onMounted } from 'vue';
|
||||
import {
|
||||
useStore,
|
||||
useStoreGetters,
|
||||
useMapGetter,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
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 { 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) {
|
||||
useTrack(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,20 +1,41 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
import {
|
||||
useFunctionGetter,
|
||||
useMapGetter,
|
||||
useStore,
|
||||
} from 'dashboard/composables/store.js';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
|
||||
export function useCaptain() {
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { isCloudFeatureEnabled, currentAccount } = useAccount();
|
||||
const { isEnterprise } = useConfig();
|
||||
const uiFlags = useMapGetter('accounts/getUIFlags');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||
const conversationId = computed(() => currentChat.value?.id);
|
||||
const draftKey = computed(
|
||||
() => `draft-${conversationId.value}-${replyMode.value}`
|
||||
);
|
||||
const draftMessage = useFunctionGetter('draftMessages/get', draftKey);
|
||||
|
||||
// === Feature Flags ===
|
||||
const captainEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
|
||||
});
|
||||
|
||||
const captainTasksEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
|
||||
});
|
||||
|
||||
// === Limits (Enterprise) ===
|
||||
const captainLimits = computed(() => {
|
||||
return currentAccount.value?.limits?.captain;
|
||||
});
|
||||
@@ -23,7 +44,6 @@ export function useCaptain() {
|
||||
if (captainLimits.value?.documents) {
|
||||
return useCamelCase(captainLimits.value.documents);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -31,7 +51,6 @@ export function useCaptain() {
|
||||
if (captainLimits.value?.responses) {
|
||||
return useCamelCase(captainLimits.value.responses);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -43,12 +62,159 @@ export function useCaptain() {
|
||||
}
|
||||
};
|
||||
|
||||
// === Error Handling ===
|
||||
/**
|
||||
* Handles API errors and displays appropriate error messages.
|
||||
* Silently returns for aborted requests.
|
||||
* @param {Error} error - The error object from the API call.
|
||||
*/
|
||||
const handleAPIError = error => {
|
||||
if (error.name === 'AbortError' || error.name === 'CanceledError') {
|
||||
return;
|
||||
}
|
||||
const errorMessage =
|
||||
error.response?.data?.error ||
|
||||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
|
||||
useAlert(errorMessage);
|
||||
};
|
||||
|
||||
// === Task Methods ===
|
||||
/**
|
||||
* Rewrites content with a specific operation.
|
||||
* @param {string} content - The content to rewrite.
|
||||
* @param {string} operation - The operation (fix_spelling_grammar, casual, professional, expand, shorten, improve, etc).
|
||||
* @param {Object} [options={}] - Additional options.
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise<{message: string, followUpContext?: Object}>} The rewritten content and optional follow-up context.
|
||||
*/
|
||||
const rewriteContent = async (content, operation, options = {}) => {
|
||||
try {
|
||||
const result = await TasksAPI.rewrite(
|
||||
{
|
||||
content: content || draftMessage.value,
|
||||
operation,
|
||||
conversationId: conversationId.value,
|
||||
},
|
||||
options.signal
|
||||
);
|
||||
const {
|
||||
data: { message: generatedMessage, follow_up_context: followUpContext },
|
||||
} = result;
|
||||
return { message: generatedMessage, followUpContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Summarizes a conversation.
|
||||
* @param {Object} [options={}] - Additional options.
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise<{message: string, followUpContext?: Object}>} The summary and optional follow-up context.
|
||||
*/
|
||||
const summarizeConversation = async (options = {}) => {
|
||||
try {
|
||||
const result = await TasksAPI.summarize(
|
||||
conversationId.value,
|
||||
options.signal
|
||||
);
|
||||
const {
|
||||
data: { message: generatedMessage, follow_up_context: followUpContext },
|
||||
} = result;
|
||||
return { message: generatedMessage, followUpContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a reply suggestion for the current conversation.
|
||||
* @param {Object} [options={}] - Additional options.
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise<{message: string, followUpContext?: Object}>} The reply suggestion and optional follow-up context.
|
||||
*/
|
||||
const getReplySuggestion = async (options = {}) => {
|
||||
try {
|
||||
const result = await TasksAPI.replySuggestion(
|
||||
conversationId.value,
|
||||
options.signal
|
||||
);
|
||||
const {
|
||||
data: { message: generatedMessage, follow_up_context: followUpContext },
|
||||
} = result;
|
||||
return { message: generatedMessage, followUpContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a follow-up message to refine a previous AI task result.
|
||||
* @param {Object} options - The follow-up options.
|
||||
* @param {Object} options.followUpContext - The follow-up context from a previous task.
|
||||
* @param {string} options.message - The follow-up message/request from the user.
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise<{message: string, followUpContext: Object}>} The follow-up response and updated context.
|
||||
*/
|
||||
const followUp = async ({ followUpContext, message, signal }) => {
|
||||
try {
|
||||
const result = await TasksAPI.followUp(
|
||||
{ followUpContext, message, conversationId: conversationId.value },
|
||||
signal
|
||||
);
|
||||
const {
|
||||
data: { message: generatedMessage, follow_up_context: updatedContext },
|
||||
} = result;
|
||||
return { message: generatedMessage, followUpContext: updatedContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '', followUpContext };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes an AI event. Routes to the appropriate method based on type.
|
||||
* @param {string} [type='improve'] - The type of AI event to process.
|
||||
* @param {string} [content=''] - The content to process.
|
||||
* @param {Object} [options={}] - Additional options.
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise<{message: string, followUpContext?: Object}>} The generated message and optional follow-up context.
|
||||
*/
|
||||
const processEvent = async (type = 'improve', content = '', options = {}) => {
|
||||
if (type === 'summarize') {
|
||||
return summarizeConversation(options);
|
||||
}
|
||||
if (type === 'reply_suggestion') {
|
||||
return getReplySuggestion(options);
|
||||
}
|
||||
// All other types are rewrite operations
|
||||
return rewriteContent(content, type, options);
|
||||
};
|
||||
|
||||
return {
|
||||
// Feature flags
|
||||
captainEnabled,
|
||||
captainTasksEnabled,
|
||||
|
||||
// Limits (Enterprise)
|
||||
captainLimits,
|
||||
documentLimits,
|
||||
responseLimits,
|
||||
fetchLimits,
|
||||
isFetchingLimits,
|
||||
|
||||
// Conversation context
|
||||
draftMessage,
|
||||
currentChat,
|
||||
|
||||
// Task methods
|
||||
rewriteContent,
|
||||
summarizeConversation,
|
||||
getReplySuggestion,
|
||||
followUp,
|
||||
processEvent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { ATTRIBUTE_TYPES } from 'dashboard/components-next/ConversationWorkflow/constants';
|
||||
|
||||
/**
|
||||
* Composable for managing conversation required attributes workflow
|
||||
*
|
||||
* This handles the logic for checking if conversations have all required
|
||||
* custom attributes filled before they can be resolved.
|
||||
*/
|
||||
export function useConversationRequiredAttributes() {
|
||||
const { currentAccount, accountId } = useAccount();
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
const conversationAttributes = useMapGetter(
|
||||
'attributes/getConversationAttributes'
|
||||
);
|
||||
|
||||
const isFeatureEnabled = computed(() =>
|
||||
isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES
|
||||
)
|
||||
);
|
||||
|
||||
const requiredAttributeKeys = computed(() => {
|
||||
if (!isFeatureEnabled.value) return [];
|
||||
return (
|
||||
currentAccount.value?.settings?.conversation_required_attributes || []
|
||||
);
|
||||
});
|
||||
|
||||
const allAttributeOptions = computed(() =>
|
||||
(conversationAttributes.value || []).map(attribute => ({
|
||||
...attribute,
|
||||
value: attribute.attributeKey,
|
||||
label: attribute.attributeDisplayName,
|
||||
type: attribute.attributeDisplayType,
|
||||
attributeValues: attribute.attributeValues,
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the full attribute definitions for only the required attributes
|
||||
* Filters allAttributeOptions to only include attributes marked as required
|
||||
*/
|
||||
const requiredAttributes = computed(
|
||||
() =>
|
||||
requiredAttributeKeys.value
|
||||
.map(key =>
|
||||
allAttributeOptions.value.find(attribute => attribute.value === key)
|
||||
)
|
||||
.filter(Boolean) // Remove any undefined attributes (deleted attributes)
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if a conversation is missing any required attributes
|
||||
*
|
||||
* @param {Object} conversationCustomAttributes - Current conversation's custom attributes
|
||||
* @returns {Object} - Analysis result with missing attributes info
|
||||
*/
|
||||
const checkMissingAttributes = (conversationCustomAttributes = {}) => {
|
||||
// If no attributes are required, conversation can be resolved
|
||||
if (!requiredAttributes.value.length) {
|
||||
return { hasMissing: false, missing: [] };
|
||||
}
|
||||
|
||||
// Find attributes that are missing or empty
|
||||
const missing = requiredAttributes.value.filter(attribute => {
|
||||
const value = conversationCustomAttributes[attribute.value];
|
||||
|
||||
// For checkbox/boolean attributes, only check if the key exists
|
||||
if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
|
||||
return !(attribute.value in conversationCustomAttributes);
|
||||
}
|
||||
|
||||
// For other attribute types, only consider null, undefined, empty string, or whitespace-only as missing
|
||||
// Allow falsy values like 0, false as they are valid filled values
|
||||
return value == null || String(value).trim() === '';
|
||||
});
|
||||
|
||||
return {
|
||||
hasMissing: missing.length > 0,
|
||||
missing,
|
||||
all: requiredAttributes.value,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
requiredAttributeKeys,
|
||||
requiredAttributes,
|
||||
checkMissingAttributes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
// Actions that map to REWRITE events (with operation attribute)
|
||||
const REWRITE_ACTIONS = [
|
||||
'improve',
|
||||
'fix_spelling_grammar',
|
||||
'casual',
|
||||
'professional',
|
||||
'expand',
|
||||
'shorten',
|
||||
'rephrase',
|
||||
'make_friendly',
|
||||
'make_formal',
|
||||
'simplify',
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets the event key suffix based on action type.
|
||||
* @param {string} action - The action type
|
||||
* @returns {string} The event key prefix (REWRITE, SUMMARIZE, or REPLY_SUGGESTION)
|
||||
*/
|
||||
function getEventPrefix(action) {
|
||||
if (action === 'summarize') return 'SUMMARIZE';
|
||||
if (action === 'reply_suggestion') return 'REPLY_SUGGESTION';
|
||||
return 'REWRITE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the analytics payload based on action type.
|
||||
* @param {string} action - The action type
|
||||
* @param {number} conversationId - The conversation ID
|
||||
* @param {number} [followUpCount] - Optional follow-up count
|
||||
* @returns {Object} The payload object
|
||||
*/
|
||||
function buildPayload(action, conversationId, followUpCount = undefined) {
|
||||
const payload = { conversationId };
|
||||
|
||||
// Add operation for rewrite actions
|
||||
if (REWRITE_ACTIONS.includes(action)) {
|
||||
payload.operation = action;
|
||||
}
|
||||
|
||||
// Add followUpCount if provided
|
||||
if (followUpCount !== undefined) {
|
||||
payload.followUpCount = followUpCount;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing Copilot reply generation state and actions.
|
||||
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
|
||||
*
|
||||
* @returns {Object} Copilot reply state and methods
|
||||
*/
|
||||
export function useCopilotReply() {
|
||||
const { processEvent, followUp, currentChat } = useCaptain();
|
||||
const { updateUISettings } = useUISettings();
|
||||
|
||||
const showEditor = ref(false);
|
||||
const isGenerating = ref(false);
|
||||
const isContentReady = ref(false);
|
||||
const generatedContent = ref('');
|
||||
const followUpContext = ref(null);
|
||||
const abortController = ref(null);
|
||||
|
||||
// Tracking state
|
||||
const currentAction = ref(null);
|
||||
const followUpCount = ref(0);
|
||||
const trackedConversationId = ref(null);
|
||||
|
||||
const conversationId = computed(() => currentChat.value?.id);
|
||||
|
||||
const isActive = computed(() => showEditor.value || isGenerating.value);
|
||||
const isButtonDisabled = computed(
|
||||
() => isGenerating.value || !isContentReady.value
|
||||
);
|
||||
const editorTransitionKey = computed(() =>
|
||||
isActive.value ? 'copilot' : 'rich'
|
||||
);
|
||||
|
||||
/**
|
||||
* Resets all copilot editor state and cancels any ongoing generation.
|
||||
* @param {boolean} [trackDismiss=true] - Whether to track dismiss event
|
||||
*/
|
||||
function reset(trackDismiss = true) {
|
||||
// Track dismiss event if there was content and we're not accepting
|
||||
if (trackDismiss && generatedContent.value && currentAction.value) {
|
||||
const eventKey = `${getEventPrefix(currentAction.value)}_DISMISSED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(
|
||||
currentAction.value,
|
||||
trackedConversationId.value,
|
||||
followUpCount.value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (abortController.value) {
|
||||
abortController.value.abort();
|
||||
abortController.value = null;
|
||||
}
|
||||
showEditor.value = false;
|
||||
isGenerating.value = false;
|
||||
isContentReady.value = false;
|
||||
generatedContent.value = '';
|
||||
followUpContext.value = null;
|
||||
currentAction.value = null;
|
||||
followUpCount.value = 0;
|
||||
trackedConversationId.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the copilot editor visibility.
|
||||
*/
|
||||
function toggleEditor() {
|
||||
showEditor.value = !showEditor.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks content as ready (called after transition completes).
|
||||
*/
|
||||
function setContentReady() {
|
||||
isContentReady.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a copilot action (e.g., improve, fix grammar).
|
||||
* @param {string} action - The action type
|
||||
* @param {string} data - The content to process
|
||||
*/
|
||||
async function execute(action, data) {
|
||||
if (action === 'ask_copilot') {
|
||||
updateUISettings({
|
||||
is_contact_sidebar_open: false,
|
||||
is_copilot_panel_open: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset without tracking dismiss (starting new action)
|
||||
reset(false);
|
||||
abortController.value = new AbortController();
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
currentAction.value = action;
|
||||
followUpCount.value = 0;
|
||||
trackedConversationId.value = conversationId.value;
|
||||
|
||||
try {
|
||||
const { message: content, followUpContext: newContext } =
|
||||
await processEvent(action, data, {
|
||||
signal: abortController.value.signal,
|
||||
});
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = newContext;
|
||||
if (content) {
|
||||
showEditor.value = true;
|
||||
// Track "Used" event on successful generation
|
||||
const eventKey = `${getEventPrefix(action)}_USED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(action, trackedConversationId.value)
|
||||
);
|
||||
}
|
||||
isGenerating.value = false;
|
||||
}
|
||||
} catch {
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a follow-up message to refine the current generated content.
|
||||
* @param {string} message - The follow-up message from the user
|
||||
*/
|
||||
async function sendFollowUp(message) {
|
||||
if (!followUpContext.value || !message.trim()) return;
|
||||
|
||||
abortController.value = new AbortController();
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
|
||||
// Track follow-up sent event
|
||||
useTrack(CAPTAIN_EVENTS.FOLLOW_UP_SENT, {
|
||||
conversationId: trackedConversationId.value,
|
||||
});
|
||||
followUpCount.value += 1;
|
||||
|
||||
try {
|
||||
const { message: content, followUpContext: updatedContext } =
|
||||
await followUp({
|
||||
followUpContext: followUpContext.value,
|
||||
message,
|
||||
signal: abortController.value.signal,
|
||||
});
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
if (content) {
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = updatedContext;
|
||||
showEditor.value = true;
|
||||
}
|
||||
isGenerating.value = false;
|
||||
}
|
||||
} catch {
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts the generated content and returns it.
|
||||
* Note: Formatting is automatically stripped by the Editor component's
|
||||
* createState function based on the channel's schema.
|
||||
* @returns {string} The content ready for the editor
|
||||
*/
|
||||
function accept() {
|
||||
const content = generatedContent.value;
|
||||
|
||||
// Track "Applied" event
|
||||
if (currentAction.value) {
|
||||
const eventKey = `${getEventPrefix(currentAction.value)}_APPLIED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(
|
||||
currentAction.value,
|
||||
trackedConversationId.value,
|
||||
followUpCount.value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Reset state without tracking dismiss
|
||||
showEditor.value = false;
|
||||
generatedContent.value = '';
|
||||
followUpContext.value = null;
|
||||
currentAction.value = null;
|
||||
followUpCount.value = 0;
|
||||
trackedConversationId.value = null;
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
return {
|
||||
showEditor,
|
||||
isGenerating,
|
||||
isContentReady,
|
||||
generatedContent,
|
||||
followUpContext,
|
||||
|
||||
isActive,
|
||||
isButtonDisabled,
|
||||
editorTransitionKey,
|
||||
|
||||
reset,
|
||||
toggleEditor,
|
||||
setContentReady,
|
||||
execute,
|
||||
sendFollowUp,
|
||||
accept,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
|
||||
/**
|
||||
* 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()
|
||||
.split(',')
|
||||
.filter(label => label.trim())
|
||||
.map(label => label.trim())
|
||||
.filter((label, index, self) => self.indexOf(label) === index);
|
||||
};
|
||||
|
||||
export function useLabelSuggestions() {
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const conversationId = computed(() => currentChat.value?.id);
|
||||
|
||||
const captainTasksEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
|
||||
});
|
||||
|
||||
const aiIntegration = computed(
|
||||
() =>
|
||||
appIntegrations.value.find(
|
||||
integration => integration.id === 'openai' && !!integration.hooks.length
|
||||
)?.hooks[0]
|
||||
);
|
||||
|
||||
const isLabelSuggestionFeatureEnabled = computed(() => {
|
||||
if (aiIntegration.value) {
|
||||
const { settings = {} } = aiIntegration.value || {};
|
||||
return !!settings.label_suggestion;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const fetchIntegrationsIfRequired = async () => {
|
||||
if (!appIntegrations.value.length) {
|
||||
await store.dispatch('integrations/get');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets label suggestions for the current conversation.
|
||||
* @returns {Promise<string[]>} An array of suggested labels.
|
||||
*/
|
||||
const getLabelSuggestions = async () => {
|
||||
if (!conversationId.value) return [];
|
||||
|
||||
try {
|
||||
const result = await TasksAPI.labelSuggestion(conversationId.value);
|
||||
const {
|
||||
data: { message: labels },
|
||||
} = result;
|
||||
return cleanLabels(labels);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchIntegrationsIfRequired();
|
||||
});
|
||||
|
||||
return {
|
||||
captainTasksEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
getLabelSuggestions,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,25 @@
|
||||
import { computed } from 'vue';
|
||||
import { useUISettings } from './useUISettings';
|
||||
import { useAccount } from './useAccount';
|
||||
|
||||
/**
|
||||
* Select translation based on locale priority.
|
||||
* @param {Object} translations - Translations object with locale keys
|
||||
* @param {string} agentLocale - Agent's preferred locale
|
||||
* @param {string} accountLocale - Account's default locale
|
||||
* @returns {string|null} Selected translation or null
|
||||
*/
|
||||
export function selectTranslation(translations, agentLocale, accountLocale) {
|
||||
if (!translations || Object.keys(translations).length === 0) return null;
|
||||
|
||||
if (agentLocale && translations[agentLocale]) {
|
||||
return translations[agentLocale];
|
||||
}
|
||||
if (accountLocale && translations[accountLocale]) {
|
||||
return translations[accountLocale];
|
||||
}
|
||||
return translations[Object.keys(translations)[0]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable to extract translation state/content from contentAttributes.
|
||||
@@ -6,6 +27,9 @@ import { computed } from 'vue';
|
||||
* @returns {Object} { hasTranslations, translationContent }
|
||||
*/
|
||||
export function useTranslations(contentAttributes) {
|
||||
const { uiSettings } = useUISettings();
|
||||
const { currentAccount } = useAccount();
|
||||
|
||||
const hasTranslations = computed(() => {
|
||||
if (!contentAttributes.value) return false;
|
||||
const { translations = {} } = contentAttributes.value;
|
||||
@@ -14,8 +38,11 @@ export function useTranslations(contentAttributes) {
|
||||
|
||||
const translationContent = computed(() => {
|
||||
if (!hasTranslations.value) return null;
|
||||
const translations = contentAttributes.value.translations;
|
||||
return translations[Object.keys(translations)[0]];
|
||||
return selectTranslation(
|
||||
contentAttributes.value.translations,
|
||||
uiSettings.value?.locale,
|
||||
currentAccount.value?.locale
|
||||
);
|
||||
});
|
||||
|
||||
return { hasTranslations, translationContent };
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
function isMacOS() {
|
||||
// Check modern userAgentData API first
|
||||
if (navigator.userAgentData?.platform) {
|
||||
return navigator.userAgentData.platform === 'macOS';
|
||||
}
|
||||
// Fallback to navigator.platform
|
||||
return (
|
||||
navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone'
|
||||
);
|
||||
}
|
||||
|
||||
export function useKbd(keys) {
|
||||
const keySymbols = {
|
||||
$mod: navigator.platform.includes('Mac') ? '⌘' : 'Ctrl',
|
||||
$mod: isMacOS() ? '⌘' : 'Ctrl',
|
||||
shift: '⇧',
|
||||
alt: '⌥',
|
||||
ctrl: 'Ctrl',
|
||||
cmd: '⌘',
|
||||
option: '⌥',
|
||||
enter: '↩',
|
||||
enter: '↵',
|
||||
tab: '⇥',
|
||||
esc: '⎋',
|
||||
};
|
||||
@@ -16,7 +27,11 @@ export function useKbd(keys) {
|
||||
return computed(() => {
|
||||
return keys
|
||||
.map(key => keySymbols[key.toLowerCase()] || key)
|
||||
.join('')
|
||||
.join(' ')
|
||||
.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
export function getModifierKey() {
|
||||
return isMacOS() ? '⌘' : 'Ctrl';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user