![]()
+import { ref, computed, watch, onMounted, useTemplateRef } from 'vue';
+
+import {
+ buildMessageSchema,
+ buildEditor,
+ EditorView,
+ MessageMarkdownTransformer,
+ MessageMarkdownSerializer,
+ EditorState,
+ Selection,
+} from '@chatwoot/prosemirror-schema';
+
+import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
+
+import NextButton from 'dashboard/components-next/button/Button.vue';
+
+const props = defineProps({
+ modelValue: { type: String, default: '' },
+ editorId: { type: String, default: '' },
+ placeholder: {
+ type: String,
+ default: 'Give copilot additional prompts, or ask anything else...',
+ },
+ generatedContent: { type: String, default: '' },
+ autofocus: {
+ type: Boolean,
+ default: true,
+ },
+ isPopout: {
+ type: Boolean,
+ default: false,
+ },
+});
+
+const emit = defineEmits([
+ 'blur',
+ 'input',
+ 'update:modelValue',
+ 'keyup',
+ 'focus',
+ 'keydown',
+ 'send',
+]);
+
+const { formatMessage } = useMessageFormatter();
+
+// Minimal schema with no marks or nodes for copilot input
+const copilotSchema = buildMessageSchema([], []);
+
+const handleSubmit = () => emit('send');
+
+const createState = (
+ content,
+ placeholder,
+ plugins = [],
+ enabledMenuOptions = []
+) => {
+ return EditorState.create({
+ doc: new MessageMarkdownTransformer(copilotSchema).parse(content),
+ plugins: buildEditor({
+ schema: copilotSchema,
+ placeholder,
+ plugins,
+ enabledMenuOptions,
+ }),
+ });
+};
+
+// we don't need them to be reactive
+// It cases weird issues where the objects are proxied
+// and then the editor doesn't work as expected
+let editorView = null;
+let state = null;
+
+// reactive data
+const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
+
+// element refs
+const editor = useTemplateRef('editor');
+
+function contentFromEditor() {
+ if (editorView) {
+ return MessageMarkdownSerializer.serialize(editorView.state.doc);
+ }
+ return '';
+}
+
+function focusEditorInputField() {
+ const { tr } = editorView.state;
+ const selection = Selection.atEnd(tr.doc);
+
+ editorView.dispatch(tr.setSelection(selection));
+ editorView.focus();
+}
+
+function emitOnChange() {
+ emit('update:modelValue', contentFromEditor());
+ emit('input', contentFromEditor());
+}
+
+function onKeyup() {
+ emit('keyup');
+}
+
+function onKeydown(view, event) {
+ emit('keydown');
+
+ // Handle Enter key to send message (Shift+Enter for new line)
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault();
+ handleSubmit();
+ return true; // Prevent ProseMirror's default Enter handling
+ }
+
+ return false; // Allow other keys to work normally
+}
+
+function onBlur() {
+ emit('blur');
+}
+
+function onFocus() {
+ emit('focus');
+}
+
+function checkSelection(editorState) {
+ const hasSelection = editorState.selection.from !== editorState.selection.to;
+ if (hasSelection === isTextSelected.value) return;
+ isTextSelected.value = hasSelection;
+}
+
+// computed properties
+const plugins = computed(() => {
+ return [];
+});
+
+const enabledMenuOptions = computed(() => {
+ return [];
+});
+
+function reloadState() {
+ state = createState(
+ props.modelValue,
+ props.placeholder,
+ plugins.value,
+ enabledMenuOptions.value
+ );
+ editorView.updateState(state);
+ focusEditorInputField();
+}
+
+function createEditorView() {
+ editorView = new EditorView(editor.value, {
+ state: state,
+ dispatchTransaction: tx => {
+ state = state.apply(tx);
+ editorView.updateState(state);
+ if (tx.docChanged) {
+ emitOnChange();
+ }
+ checkSelection(state);
+ },
+ handleDOMEvents: {
+ keyup: onKeyup,
+ focus: onFocus,
+ blur: onBlur,
+ keydown: onKeydown,
+ },
+ });
+}
+
+// watchers
+watch(
+ computed(() => props.modelValue),
+ (newValue = '') => {
+ if (newValue !== contentFromEditor()) {
+ reloadState();
+ }
+ }
+);
+
+watch(
+ computed(() => props.editorId),
+ () => {
+ reloadState();
+ }
+);
+
+// lifecycle
+onMounted(() => {
+ state = createState(
+ props.modelValue,
+ props.placeholder,
+ plugins.value,
+ enabledMenuOptions.value
+ );
+
+ createEditorView();
+ editorView.updateState(state);
+
+ if (props.autofocus) {
+ focusEditorInputField();
+ }
+});
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue b/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue
new file mode 100644
index 000000000..27353dea4
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/WootWriter/CopilotMenuBar.vue
@@ -0,0 +1,259 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue
new file mode 100644
index 000000000..e23cdbf7e
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index 850ca5f4b..4e0722c25 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -16,13 +16,16 @@ import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import TagAgents from '../conversation/TagAgents.vue';
import VariableList from '../conversation/VariableList.vue';
import TagTools from '../conversation/TagTools.vue';
+import CopilotMenuBar from './CopilotMenuBar.vue';
import { useEmitter } from 'dashboard/composables/emitter';
import { useI18n } from 'vue-i18n';
+import { useCaptain } from 'dashboard/composables/useCaptain';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
+import { vOnClickOutside } from '@vueuse/components';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@@ -100,13 +103,16 @@ const emit = defineEmits([
'focus',
'input',
'update:modelValue',
+ 'executeCopilotAction',
]);
const { t } = useI18n();
+const { captainTasksEnabled } = useCaptain();
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
+const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium)
@@ -116,17 +122,24 @@ const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
const formatType = props.isPrivate
- ? DEFAULT_FORMATTING
+ ? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value;
- const formatting = getFormattingForEditor(formatType);
+ const formatting = getFormattingForEditor(
+ formatType,
+ captainTasksEnabled.value
+ );
return buildMessageSchema(formatting.marks, formatting.nodes);
});
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
- ? DEFAULT_FORMATTING
+ ? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value || DEFAULT_FORMATTING;
- const formatting = getFormattingForEditor(formatType);
+ const formatting = getFormattingForEditor(
+ formatType,
+ captainTasksEnabled.value
+ );
+
return formatting.menu;
});
@@ -185,6 +198,21 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor');
+const handleCopilotAction = actionKey => {
+ if (actionKey === 'improve_selection' && editorView?.state) {
+ const { from, to } = editorView.state.selection;
+ const selectedText = editorView.state.doc.textBetween(from, to).trim();
+
+ if (from !== to && selectedText) {
+ emit('executeCopilotAction', 'improve', selectedText);
+ }
+ } else {
+ emit('executeCopilotAction', actionKey);
+ }
+
+ showSelectionMenu.value = false;
+};
+
const contentFromEditor = () => {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
};
@@ -367,13 +395,23 @@ function openFileBrowser() {
imageUpload.value.click();
}
+function handleCopilotClick() {
+ showSelectionMenu.value = !showSelectionMenu.value;
+}
+
+function handleClickOutside(event) {
+ // Check if the clicked element or its parents have the ignored class
+ if (event.target.closest('.ProseMirror-copilot')) return;
+ showSelectionMenu.value = false;
+}
+
function reloadState(content = props.modelValue) {
const unrefContent = unref(content);
state = createState(
unrefContent,
props.placeholder,
plugins.value,
- { onImageUpload: openFileBrowser },
+ { onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -595,7 +633,12 @@ function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
// Use the editor's current schema to ensure compatibility with buildMessageSchema
const currentSchema = editorView.state.schema;
- let node = new MessageMarkdownTransformer(currentSchema).parse(content);
+ // Strip unsupported formatting before parsing to ensure content can be inserted
+ // into channels that don't support certain markdown features (e.g., API channels)
+ const sanitizedContent = stripUnsupportedFormatting(content, currentSchema);
+ let node = new MessageMarkdownTransformer(currentSchema).parse(
+ sanitizedContent
+ );
insertNodeIntoEditor(node, from, undefined);
}
@@ -757,7 +800,7 @@ onMounted(() => {
props.modelValue,
props.placeholder,
plugins.value,
- { onImageUpload: openFileBrowser },
+ { onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -802,6 +845,14 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
:search-key="toolSearchKey"
@select-tool="content => insertSpecialContent('tool', content)"
/>
+
}
*/
const isPrivate = computed(() => {
- return props.disabled || props.mode === REPLY_EDITOR_MODES.NOTE;
+ if (props.isReplyRestricted) {
+ // Force switch to private note when replies are restricted
+ return true;
+ }
+ // Otherwise respect the current mode
+ return props.mode === REPLY_EDITOR_MODES.NOTE;
});
/**
@@ -60,9 +70,9 @@ const translateValue = computed(() => {
diff --git a/app/javascript/dashboard/components/widgets/conversation/CopilotEditorSection.vue b/app/javascript/dashboard/components/widgets/conversation/CopilotEditorSection.vue
new file mode 100644
index 000000000..b22ac308d
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/conversation/CopilotEditorSection.vue
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+ {{ $t('CONVERSATION.REPLYBOX.COPILOT_THINKING') }}
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/widgets/conversation/MessageSignatureMissingAlert.vue b/app/javascript/dashboard/components/widgets/conversation/MessageSignatureMissingAlert.vue
index a10d933bc..6f14488c9 100644
--- a/app/javascript/dashboard/components/widgets/conversation/MessageSignatureMissingAlert.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/MessageSignatureMissingAlert.vue
@@ -11,7 +11,7 @@ const openProfileSettings = () => {
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
index e5a6ce176..0f4a8c93d 100644
--- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue
@@ -10,9 +10,8 @@ import {
getCurrentInstance,
} from 'vue';
-import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
-import { useAI } from 'dashboard/composables/useAI';
+import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import {
@@ -48,14 +47,12 @@ const store = useStore();
const route = useRoute();
const { t } = useI18n();
const instance = getCurrentInstance();
-const { isEnterprise } = useConfig();
const {
- isAIIntegrationEnabled,
+ captainTasksEnabled,
isLabelSuggestionFeatureEnabled,
- fetchIntegrationsIfRequired,
- fetchLabelSuggestions,
-} = useAI();
+ getLabelSuggestions,
+} = useLabelSuggestions();
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
@@ -99,8 +96,8 @@ const isOpen = computed(() => {
const shouldShowLabelSuggestions = computed(() => {
return (
isOpen.value &&
- isEnterprise &&
- isAIIntegrationEnabled.value &&
+ captainTasksEnabled.value &&
+ isLabelSuggestionFeatureEnabled.value &&
!messageSentSinceOpened.value
);
});
@@ -396,24 +393,15 @@ const fetchSuggestions = async () => {
return;
}
- if (!isEnterprise) {
- return;
- }
-
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = currentChat.value?.labels || [];
if (existingLabels.length > 0) return;
- // method available in mixin, need to ensure that integrations are present
- await fetchIntegrationsIfRequired();
-
- if (!isLabelSuggestionFeatureEnabled.value) {
+ if (!captainTasksEnabled.value && !isLabelSuggestionFeatureEnabled.value) {
return;
}
- labelSuggestions.value = await fetchLabelSuggestions({
- conversationId: currentChat.value.id,
- });
+ labelSuggestions.value = await getLabelSuggestions();
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index ae9115c65..8c4e4657c 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -12,7 +12,9 @@ import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.v
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
+import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
+import CopilotEditorSection from './CopilotEditorSection.vue';
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
import ReplyBoxBanner from './ReplyBoxBanner.vue';
import QuotedEmailPreview from './QuotedEmailPreview.vue';
@@ -21,6 +23,7 @@ import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vu
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
+import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import {
getMessageVariables,
getUndefinedVariablesInMessage,
@@ -45,6 +48,8 @@ import {
removeSignature,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
+import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
+import { useKbd } from 'dashboard/composables/utils/useKbd';
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -70,6 +75,8 @@ export default {
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
+ CopilotEditorSection,
+ CopilotReplyBottomPanel,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -89,6 +96,8 @@ export default {
} = useUISettings();
const replyEditor = useTemplateRef('replyEditor');
+ const copilot = useCopilotReply();
+ const shortcutKey = useKbd(['$mod', '+', 'enter']);
return {
uiSettings,
@@ -97,6 +106,8 @@ export default {
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
replyEditor,
+ copilot,
+ shortcutKey,
};
},
data() {
@@ -267,7 +278,7 @@ export default {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
}
const keyLabel = this.isEditorHotKeyEnabled('cmd_enter')
- ? '(⌘ + ↵)'
+ ? `(${this.shortcutKey})`
: '(↵)';
return `${sendMessageText} ${keyLabel}`;
},
@@ -400,6 +411,9 @@ export default {
!!this.quotedEmailText
);
},
+ isDefaultEditorMode() {
+ return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
+ },
},
watch: {
currentChat(conversation, oldConversation) {
@@ -409,6 +423,8 @@ export default {
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
// like self-assign or other updates that do not actually change the conversation context
this.setCCAndToEmailsFromLastChat();
+ // Reset Copilot editor state (includes cancelling ongoing generation)
+ this.copilot.reset();
}
if (this.isOnPrivateNote) {
@@ -478,6 +494,7 @@ export default {
this.onNewConversationModalActive
);
emitter.on(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
+ emitter.on(CMD_AI_ASSIST, this.executeCopilotAction);
},
unmounted() {
document.removeEventListener('paste', this.onPaste);
@@ -488,6 +505,7 @@ export default {
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
+ emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
},
methods: {
handleInsert(article) {
@@ -613,7 +631,9 @@ export default {
},
'$mod+Enter': {
action: () => {
- if (this.isAValidEvent('cmd_enter')) {
+ if (this.copilot.isActive.value && this.isFocused) {
+ this.onSubmitCopilotReply();
+ } else if (this.isAValidEvent('cmd_enter')) {
this.onSendReply();
}
},
@@ -830,6 +850,9 @@ export default {
this.updateEditorSelectionWith = content;
this.onFocus();
},
+ executeCopilotAction(action, data) {
+ this.copilot.execute(action, data);
+ },
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
@@ -1095,6 +1118,9 @@ export default {
togglePopout() {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
},
+ onSubmitCopilotReply() {
+ this.message = this.copilot.accept();
+ },
},
};
@@ -1105,11 +1131,17 @@ export default {
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+import Icon from 'next/icon/Icon.vue';
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/composables/commands/spec/useConversationHotKeys.spec.js b/app/javascript/dashboard/composables/commands/spec/useConversationHotKeys.spec.js
index 69bc46f58..7517028eb 100644
--- a/app/javascript/dashboard/composables/commands/spec/useConversationHotKeys.spec.js
+++ b/app/javascript/dashboard/composables/commands/spec/useConversationHotKeys.spec.js
@@ -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'
diff --git a/app/javascript/dashboard/composables/commands/useConversationHotKeys.js b/app/javascript/dashboard/composables/commands/useConversationHotKeys.js
index 30e138165..66b4da28b 100644
--- a/app/javascript/dashboard/composables/commands/useConversationHotKeys.js
+++ b/app/javascript/dashboard/composables/commands/useConversationHotKeys.js
@@ -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;
diff --git a/app/javascript/dashboard/composables/spec/useAI.spec.js b/app/javascript/dashboard/composables/spec/useAI.spec.js
deleted file mode 100644
index 2431fe196..000000000
--- a/app/javascript/dashboard/composables/spec/useAI.spec.js
+++ /dev/null
@@ -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']);
- });
-});
diff --git a/app/javascript/dashboard/composables/spec/useCaptain.spec.js b/app/javascript/dashboard/composables/spec/useCaptain.spec.js
new file mode 100644
index 000000000..33fa0ce08
--- /dev/null
+++ b/app/javascript/dashboard/composables/spec/useCaptain.spec.js
@@ -0,0 +1,184 @@
+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';
+import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
+
+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', () => ({
+ OPEN_AI_EVENTS: {
+ TEST_EVENT: 'open_ai_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('records analytics correctly', async () => {
+ const { recordAnalytics } = useCaptain();
+
+ await recordAnalytics('TEST_EVENT', { data: 'test' });
+
+ expect(analyticsHelper.track).toHaveBeenCalledWith('open_ai_test_event', {
+ type: 'TEST_EVENT',
+ data: 'test',
+ });
+ });
+
+ 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();
+ });
+});
diff --git a/app/javascript/dashboard/composables/useAI.js b/app/javascript/dashboard/composables/useAI.js
deleted file mode 100644
index 9076d5716..000000000
--- a/app/javascript/dashboard/composables/useAI.js
+++ /dev/null
@@ -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