feat: add captain editor events (#13524)
## Description
Adds missing analytics instrumentation for the editor AI funnel so we
can measure end-to-end usage and outcome quality.
### What was added
- Captain: Editor AI menu opened
- Captain: Generation failed
- Captain: AI-assisted message sent
### Behavior covered
- Tracks AI button click + menu open from both entry points:
- top panel sparkle button
- inline editor copilot button
- Tracks generation failures (initial + follow-up stages).
- Tracks whether accepted AI content was sent as-is or edited before
send.
### Notes
- Applies to editor Captain accept/send flow
(rewrite/summarize/reply_suggestion + follow-ups).
- Does not change Copilot sidebar flow instrumentation.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
### Manual verification steps
<img width="1906" height="832" alt="image"
src="https://github.com/user-attachments/assets/f0ade43b-aa8d-41be-8ca2-20a091a81f60"
/>
<img width="828" height="280" alt="image"
src="https://github.com/user-attachments/assets/be76219e-fb61-4a6e-bff5-dc085b0a3cc9"
/>
<img width="415" height="147" alt="image"
src="https://github.com/user-attachments/assets/36802c5c-33a7-49ed-bf7e-f0b02d86dccc"
/>
<img width="2040" height="516" alt="image"
src="https://github.com/user-attachments/assets/74b95288-bc86-4312-a282-14211ae8f25c"
/>
1. Open a conversation with Captain tasks enabled.
2. Click AI button in top panel and inline editor.
3. Confirm analytics events fire for:
- AI menu opened
4. Run an AI action and force a failure scenario (or empty response
path) and confirm generation-failed event.
5. Accept AI output, then:
- send without changes -> editedBeforeSend: false
- edit then send -> editedBeforeSend: true
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
This commit is contained in:
@@ -28,7 +28,10 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import {
|
||||||
|
CONVERSATION_EVENTS,
|
||||||
|
CAPTAIN_EVENTS,
|
||||||
|
} from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -86,6 +89,7 @@ const props = defineProps({
|
|||||||
// are triggered except when this flag is true
|
// are triggered except when this flag is true
|
||||||
allowSignature: { type: Boolean, default: false },
|
allowSignature: { type: Boolean, default: false },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
|
conversationId: { type: Number, default: null },
|
||||||
medium: { type: String, default: '' },
|
medium: { type: String, default: '' },
|
||||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||||
focusOnMount: { type: Boolean, default: true },
|
focusOnMount: { type: Boolean, default: true },
|
||||||
@@ -396,7 +400,14 @@ function openFileBrowser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCopilotClick() {
|
function handleCopilotClick() {
|
||||||
showSelectionMenu.value = !showSelectionMenu.value;
|
const isOpening = !showSelectionMenu.value;
|
||||||
|
if (isOpening) {
|
||||||
|
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
|
||||||
|
conversationId: props.conversationId,
|
||||||
|
entryPoint: 'inline',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showSelectionMenu.value = isOpening;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClickOutside(event) {
|
function handleClickOutside(event) {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||||
|
import { useTrack } from 'dashboard/composables';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
||||||
|
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import EditorModeToggle from './EditorModeToggle.vue';
|
import EditorModeToggle from './EditorModeToggle.vue';
|
||||||
import CopilotMenuBar from './CopilotMenuBar.vue';
|
import CopilotMenuBar from './CopilotMenuBar.vue';
|
||||||
@@ -31,6 +33,10 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
conversationId: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
isMessageLengthReachingThreshold: {
|
isMessageLengthReachingThreshold: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: () => false,
|
default: () => false,
|
||||||
@@ -69,7 +75,14 @@ export default {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleCopilotMenu = () => {
|
const toggleCopilotMenu = () => {
|
||||||
showCopilotMenu.value = !showCopilotMenu.value;
|
const isOpening = !showCopilotMenu.value;
|
||||||
|
if (isOpening) {
|
||||||
|
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
|
||||||
|
conversationId: props.conversationId,
|
||||||
|
entryPoint: 'top_panel',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showCopilotMenu.value = isOpening;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClickOutside = () => {
|
const handleClickOutside = () => {
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ import {
|
|||||||
truncatePreviewText,
|
truncatePreviewText,
|
||||||
appendQuotedTextToMessage,
|
appendQuotedTextToMessage,
|
||||||
} from 'dashboard/helper/quotedEmailHelper';
|
} from 'dashboard/helper/quotedEmailHelper';
|
||||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
import {
|
||||||
|
CONVERSATION_EVENTS,
|
||||||
|
CAPTAIN_EVENTS,
|
||||||
|
} from '../../../helper/AnalyticsHelper/events';
|
||||||
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
@@ -136,6 +139,7 @@ export default {
|
|||||||
newConversationModalActive: false,
|
newConversationModalActive: false,
|
||||||
showArticleSearchPopover: false,
|
showArticleSearchPopover: false,
|
||||||
hasRecordedAudio: false,
|
hasRecordedAudio: false,
|
||||||
|
copilotAcceptedMessages: {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -508,6 +512,24 @@ export default {
|
|||||||
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
|
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
getDraftKey(
|
||||||
|
conversationId = this.conversationIdByRoute,
|
||||||
|
replyType = this.replyType
|
||||||
|
) {
|
||||||
|
return `draft-${conversationId}-${replyType}`;
|
||||||
|
},
|
||||||
|
getCopilotAcceptedMessage(replyType = this.replyType) {
|
||||||
|
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||||
|
return this.copilotAcceptedMessages[key] || '';
|
||||||
|
},
|
||||||
|
setCopilotAcceptedMessage(message, replyType = this.replyType) {
|
||||||
|
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||||
|
this.copilotAcceptedMessages[key] = trimContent(message || '');
|
||||||
|
},
|
||||||
|
clearCopilotAcceptedMessage(replyType = this.replyType) {
|
||||||
|
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||||
|
delete this.copilotAcceptedMessages[key];
|
||||||
|
},
|
||||||
handleInsert(article) {
|
handleInsert(article) {
|
||||||
const { url, title } = article;
|
const { url, title } = article;
|
||||||
// Removing empty lines from the title
|
// Removing empty lines from the title
|
||||||
@@ -559,7 +581,7 @@ export default {
|
|||||||
},
|
},
|
||||||
saveDraft(conversationId, replyType) {
|
saveDraft(conversationId, replyType) {
|
||||||
if (this.message || this.message === '') {
|
if (this.message || this.message === '') {
|
||||||
const key = `draft-${conversationId}-${replyType}`;
|
const key = this.getDraftKey(conversationId, replyType);
|
||||||
const draftToSave = trimContent(this.message || '');
|
const draftToSave = trimContent(this.message || '');
|
||||||
|
|
||||||
this.$store.dispatch('draftMessages/set', {
|
this.$store.dispatch('draftMessages/set', {
|
||||||
@@ -574,7 +596,7 @@ export default {
|
|||||||
},
|
},
|
||||||
getFromDraft() {
|
getFromDraft() {
|
||||||
if (this.conversationIdByRoute) {
|
if (this.conversationIdByRoute) {
|
||||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
const key = this.getDraftKey();
|
||||||
const messageFromStore =
|
const messageFromStore =
|
||||||
this.$store.getters['draftMessages/get'](key) || '';
|
this.$store.getters['draftMessages/get'](key) || '';
|
||||||
|
|
||||||
@@ -597,7 +619,7 @@ export default {
|
|||||||
},
|
},
|
||||||
removeFromDraft() {
|
removeFromDraft() {
|
||||||
if (this.conversationIdByRoute) {
|
if (this.conversationIdByRoute) {
|
||||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
const key = this.getDraftKey();
|
||||||
this.$store.dispatch('draftMessages/delete', { key });
|
this.$store.dispatch('draftMessages/delete', { key });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -708,6 +730,7 @@ export default {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.showMentions) {
|
if (!this.showMentions) {
|
||||||
|
const copilotAcceptedMessage = this.getCopilotAcceptedMessage();
|
||||||
const isOnWhatsApp =
|
const isOnWhatsApp =
|
||||||
this.isATwilioWhatsAppChannel ||
|
this.isATwilioWhatsAppChannel ||
|
||||||
this.isAWhatsAppCloudChannel ||
|
this.isAWhatsAppCloudChannel ||
|
||||||
@@ -717,10 +740,17 @@ export default {
|
|||||||
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
|
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
|
||||||
const isOnInstagram = this.isAnInstagramChannel;
|
const isOnInstagram = this.isAnInstagramChannel;
|
||||||
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
|
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
|
||||||
this.sendMessageAsMultipleMessages(this.message);
|
this.sendMessageAsMultipleMessages(
|
||||||
|
this.message,
|
||||||
|
copilotAcceptedMessage
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
const messagePayload = this.getMessagePayload(this.message);
|
const messagePayload = this.getMessagePayload(this.message);
|
||||||
this.sendMessage(messagePayload);
|
this.sendMessage(
|
||||||
|
messagePayload,
|
||||||
|
this.message,
|
||||||
|
copilotAcceptedMessage
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isPrivate) {
|
if (!this.isPrivate) {
|
||||||
@@ -732,13 +762,53 @@ export default {
|
|||||||
this.$emit('update:popOutReplyBox', false);
|
this.$emit('update:popOutReplyBox', false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
sendMessageAsMultipleMessages(message) {
|
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
|
||||||
const messages = this.getMultipleMessagesPayload(message);
|
const messages = this.getMultipleMessagesPayload(message);
|
||||||
messages.forEach(messagePayload => {
|
messages.forEach(messagePayload => {
|
||||||
this.sendMessage(messagePayload);
|
this.sendMessage(
|
||||||
|
messagePayload,
|
||||||
|
messagePayload.message || '',
|
||||||
|
copilotAcceptedMessage
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
sendMessageAnalyticsData(isPrivate) {
|
sendMessageAnalyticsData(
|
||||||
|
isPrivate,
|
||||||
|
{ editorMessage = '', copilotAcceptedMessage = '' } = {}
|
||||||
|
) {
|
||||||
|
const normalizeForComparison = message => {
|
||||||
|
let normalizedMessage = message || '';
|
||||||
|
|
||||||
|
if (this.sendWithSignature && this.messageSignature && !isPrivate) {
|
||||||
|
const effectiveChannelType = getEffectiveChannelType(
|
||||||
|
this.channelType,
|
||||||
|
this.inbox?.medium || ''
|
||||||
|
);
|
||||||
|
normalizedMessage = removeSignature(
|
||||||
|
normalizedMessage,
|
||||||
|
this.messageSignature,
|
||||||
|
effectiveChannelType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimContent(normalizedMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizedAcceptedMessage = normalizeForComparison(
|
||||||
|
copilotAcceptedMessage
|
||||||
|
);
|
||||||
|
const normalizedEditorMessage = normalizeForComparison(editorMessage);
|
||||||
|
|
||||||
|
if (normalizedAcceptedMessage && normalizedEditorMessage) {
|
||||||
|
useTrack(CAPTAIN_EVENTS.AI_ASSISTED_MESSAGE_SENT, {
|
||||||
|
conversationId: this.conversationIdByRoute,
|
||||||
|
channelType: this.channelType,
|
||||||
|
editedBeforeSend:
|
||||||
|
normalizedAcceptedMessage !== normalizedEditorMessage,
|
||||||
|
isPrivate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Analytics data for message signature is enabled or not in channels
|
// Analytics data for message signature is enabled or not in channels
|
||||||
return isPrivate
|
return isPrivate
|
||||||
? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
|
? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
|
||||||
@@ -772,7 +842,11 @@ export default {
|
|||||||
this.confirmOnSendReply();
|
this.confirmOnSendReply();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async sendMessage(messagePayload) {
|
async sendMessage(
|
||||||
|
messagePayload,
|
||||||
|
editorMessage = '',
|
||||||
|
copilotAcceptedMessage = ''
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
await this.$store.dispatch(
|
await this.$store.dispatch(
|
||||||
'createPendingMessageAndSend',
|
'createPendingMessageAndSend',
|
||||||
@@ -781,7 +855,10 @@ export default {
|
|||||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||||
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
|
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
|
||||||
this.removeFromDraft();
|
this.removeFromDraft();
|
||||||
this.sendMessageAnalyticsData(messagePayload.private);
|
this.sendMessageAnalyticsData(messagePayload.private, {
|
||||||
|
editorMessage,
|
||||||
|
copilotAcceptedMessage,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
|
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
|
||||||
@@ -855,6 +932,7 @@ export default {
|
|||||||
},
|
},
|
||||||
clearMessage() {
|
clearMessage() {
|
||||||
this.message = '';
|
this.message = '';
|
||||||
|
this.clearCopilotAcceptedMessage();
|
||||||
if (this.sendWithSignature && !this.isPrivate) {
|
if (this.sendWithSignature && !this.isPrivate) {
|
||||||
// if signature is enabled, append it to the message
|
// if signature is enabled, append it to the message
|
||||||
const effectiveChannelType = getEffectiveChannelType(
|
const effectiveChannelType = getEffectiveChannelType(
|
||||||
@@ -1119,7 +1197,9 @@ export default {
|
|||||||
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
|
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
|
||||||
},
|
},
|
||||||
onSubmitCopilotReply() {
|
onSubmitCopilotReply() {
|
||||||
this.message = this.copilot.accept();
|
const acceptedMessage = this.copilot.accept();
|
||||||
|
this.message = acceptedMessage;
|
||||||
|
this.setCopilotAcceptedMessage(acceptedMessage);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1130,6 +1210,7 @@ export default {
|
|||||||
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
|
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
|
||||||
<ReplyTopPanel
|
<ReplyTopPanel
|
||||||
:mode="replyType"
|
:mode="replyType"
|
||||||
|
:conversation-id="conversationId"
|
||||||
:is-reply-restricted="isReplyRestricted"
|
:is-reply-restricted="isReplyRestricted"
|
||||||
:disabled="
|
:disabled="
|
||||||
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
|
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
|
||||||
@@ -1204,6 +1285,7 @@ export default {
|
|||||||
<WootMessageEditor
|
<WootMessageEditor
|
||||||
v-else-if="!showAudioRecorderEditor"
|
v-else-if="!showAudioRecorderEditor"
|
||||||
v-model="message"
|
v-model="message"
|
||||||
|
:conversation-id="conversationId"
|
||||||
:editor-id="editorStateId"
|
:editor-id="editorStateId"
|
||||||
class="input popover-prosemirror-menu"
|
class="input popover-prosemirror-menu"
|
||||||
:is-private="isOnPrivateNote"
|
:is-private="isOnPrivateNote"
|
||||||
|
|||||||
12
app/javascript/dashboard/composables/captain/constants.js
Normal file
12
app/javascript/dashboard/composables/captain/constants.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export const CAPTAIN_ERROR_TYPES = Object.freeze({
|
||||||
|
ABORTED: 'aborted',
|
||||||
|
API_ERROR: 'api_error',
|
||||||
|
HTTP_PREFIX: 'http_',
|
||||||
|
ABORT_ERROR: 'AbortError',
|
||||||
|
CANCELED_ERROR: 'CanceledError',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CAPTAIN_GENERATION_FAILURE_REASONS = Object.freeze({
|
||||||
|
EMPTY_RESPONSE: 'empty_response',
|
||||||
|
EXCEPTION: 'exception',
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||||
|
import { CAPTAIN_ERROR_TYPES } from 'dashboard/composables/captain/constants';
|
||||||
|
|
||||||
export function useCaptain() {
|
export function useCaptain() {
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -69,7 +70,10 @@ export function useCaptain() {
|
|||||||
* @param {Error} error - The error object from the API call.
|
* @param {Error} error - The error object from the API call.
|
||||||
*/
|
*/
|
||||||
const handleAPIError = error => {
|
const handleAPIError = error => {
|
||||||
if (error.name === 'AbortError' || error.name === 'CanceledError') {
|
if (
|
||||||
|
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||||
|
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
@@ -78,6 +82,24 @@ export function useCaptain() {
|
|||||||
useAlert(errorMessage);
|
useAlert(errorMessage);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies API error types for downstream analytics.
|
||||||
|
* @param {Error} error
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const getErrorType = error => {
|
||||||
|
if (
|
||||||
|
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||||
|
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||||
|
) {
|
||||||
|
return CAPTAIN_ERROR_TYPES.ABORTED;
|
||||||
|
}
|
||||||
|
if (error.response?.status) {
|
||||||
|
return `${CAPTAIN_ERROR_TYPES.HTTP_PREFIX}${error.response.status}`;
|
||||||
|
}
|
||||||
|
return CAPTAIN_ERROR_TYPES.API_ERROR;
|
||||||
|
};
|
||||||
|
|
||||||
// === Task Methods ===
|
// === Task Methods ===
|
||||||
/**
|
/**
|
||||||
* Rewrites content with a specific operation.
|
* Rewrites content with a specific operation.
|
||||||
@@ -103,7 +125,7 @@ export function useCaptain() {
|
|||||||
return { message: generatedMessage, followUpContext };
|
return { message: generatedMessage, followUpContext };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleAPIError(error);
|
handleAPIError(error);
|
||||||
return { message: '' };
|
return { message: '', errorType: getErrorType(error) };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,7 +147,7 @@ export function useCaptain() {
|
|||||||
return { message: generatedMessage, followUpContext };
|
return { message: generatedMessage, followUpContext };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleAPIError(error);
|
handleAPIError(error);
|
||||||
return { message: '' };
|
return { message: '', errorType: getErrorType(error) };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,7 +169,7 @@ export function useCaptain() {
|
|||||||
return { message: generatedMessage, followUpContext };
|
return { message: generatedMessage, followUpContext };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleAPIError(error);
|
handleAPIError(error);
|
||||||
return { message: '' };
|
return { message: '', errorType: getErrorType(error) };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,7 +193,11 @@ export function useCaptain() {
|
|||||||
return { message: generatedMessage, followUpContext: updatedContext };
|
return { message: generatedMessage, followUpContext: updatedContext };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleAPIError(error);
|
handleAPIError(error);
|
||||||
return { message: '', followUpContext };
|
return {
|
||||||
|
message: '',
|
||||||
|
followUpContext,
|
||||||
|
errorType: getErrorType(error),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { useCaptain } from 'dashboard/composables/useCaptain';
|
|||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useTrack } from 'dashboard/composables';
|
import { useTrack } from 'dashboard/composables';
|
||||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
import {
|
||||||
|
CAPTAIN_ERROR_TYPES,
|
||||||
|
CAPTAIN_GENERATION_FAILURE_REASONS,
|
||||||
|
} from 'dashboard/composables/captain/constants';
|
||||||
|
|
||||||
// Actions that map to REWRITE events (with operation attribute)
|
// Actions that map to REWRITE events (with operation attribute)
|
||||||
const REWRITE_ACTIONS = [
|
const REWRITE_ACTIONS = [
|
||||||
@@ -52,6 +56,20 @@ function buildPayload(action, conversationId, followUpCount = undefined) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trackGenerationFailure({
|
||||||
|
action,
|
||||||
|
conversationId,
|
||||||
|
followUpCount = undefined,
|
||||||
|
stage,
|
||||||
|
reason,
|
||||||
|
}) {
|
||||||
|
useTrack(CAPTAIN_EVENTS.GENERATION_FAILED, {
|
||||||
|
...buildPayload(action, conversationId, followUpCount),
|
||||||
|
stage,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for managing Copilot reply generation state and actions.
|
* Composable for managing Copilot reply generation state and actions.
|
||||||
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
|
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
|
||||||
@@ -146,7 +164,8 @@ export function useCopilotReply() {
|
|||||||
|
|
||||||
// Reset without tracking dismiss (starting new action)
|
// Reset without tracking dismiss (starting new action)
|
||||||
reset(false);
|
reset(false);
|
||||||
abortController.value = new AbortController();
|
const requestController = new AbortController();
|
||||||
|
abortController.value = requestController;
|
||||||
isGenerating.value = true;
|
isGenerating.value = true;
|
||||||
isContentReady.value = false;
|
isContentReady.value = false;
|
||||||
currentAction.value = action;
|
currentAction.value = action;
|
||||||
@@ -154,28 +173,66 @@ export function useCopilotReply() {
|
|||||||
trackedConversationId.value = conversationId.value;
|
trackedConversationId.value = conversationId.value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { message: content, followUpContext: newContext } =
|
const {
|
||||||
await processEvent(action, data, {
|
message: content,
|
||||||
signal: abortController.value.signal,
|
followUpContext: newContext,
|
||||||
});
|
errorType,
|
||||||
|
} = await processEvent(action, data, {
|
||||||
|
signal: requestController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
if (!abortController.value?.signal.aborted) {
|
if (requestController.signal.aborted) return;
|
||||||
generatedContent.value = content;
|
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||||
followUpContext.value = newContext;
|
if (abortController.value === requestController) {
|
||||||
if (content) {
|
isGenerating.value = false;
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
if (!abortController.value?.signal.aborted) {
|
generatedContent.value = content;
|
||||||
isGenerating.value = false;
|
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)
|
||||||
|
);
|
||||||
|
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||||
|
trackGenerationFailure({
|
||||||
|
action,
|
||||||
|
conversationId: trackedConversationId.value,
|
||||||
|
stage: 'initial',
|
||||||
|
reason: errorType,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
trackGenerationFailure({
|
||||||
|
action,
|
||||||
|
conversationId: trackedConversationId.value,
|
||||||
|
stage: 'initial',
|
||||||
|
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
isGenerating.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
requestController.signal.aborted ||
|
||||||
|
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||||
|
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackGenerationFailure({
|
||||||
|
action,
|
||||||
|
conversationId: trackedConversationId.value,
|
||||||
|
stage: 'initial',
|
||||||
|
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
|
||||||
|
});
|
||||||
|
isGenerating.value = false;
|
||||||
|
} finally {
|
||||||
|
if (abortController.value === requestController) {
|
||||||
|
abortController.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,7 +244,8 @@ export function useCopilotReply() {
|
|||||||
async function sendFollowUp(message) {
|
async function sendFollowUp(message) {
|
||||||
if (!followUpContext.value || !message.trim()) return;
|
if (!followUpContext.value || !message.trim()) return;
|
||||||
|
|
||||||
abortController.value = new AbortController();
|
const requestController = new AbortController();
|
||||||
|
abortController.value = requestController;
|
||||||
isGenerating.value = true;
|
isGenerating.value = true;
|
||||||
isContentReady.value = false;
|
isContentReady.value = false;
|
||||||
|
|
||||||
@@ -198,24 +256,65 @@ export function useCopilotReply() {
|
|||||||
followUpCount.value += 1;
|
followUpCount.value += 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { message: content, followUpContext: updatedContext } =
|
const {
|
||||||
await followUp({
|
message: content,
|
||||||
followUpContext: followUpContext.value,
|
followUpContext: updatedContext,
|
||||||
message,
|
errorType,
|
||||||
signal: abortController.value.signal,
|
} = await followUp({
|
||||||
});
|
followUpContext: followUpContext.value,
|
||||||
|
message,
|
||||||
|
signal: requestController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
if (!abortController.value?.signal.aborted) {
|
if (requestController.signal.aborted) return;
|
||||||
if (content) {
|
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||||
generatedContent.value = content;
|
if (abortController.value === requestController) {
|
||||||
followUpContext.value = updatedContext;
|
isGenerating.value = false;
|
||||||
showEditor.value = true;
|
|
||||||
}
|
}
|
||||||
isGenerating.value = false;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
if (!abortController.value?.signal.aborted) {
|
if (content) {
|
||||||
isGenerating.value = false;
|
generatedContent.value = content;
|
||||||
|
followUpContext.value = updatedContext;
|
||||||
|
showEditor.value = true;
|
||||||
|
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||||
|
trackGenerationFailure({
|
||||||
|
action: currentAction.value,
|
||||||
|
conversationId: trackedConversationId.value,
|
||||||
|
followUpCount: followUpCount.value,
|
||||||
|
stage: 'follow_up',
|
||||||
|
reason: errorType,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
trackGenerationFailure({
|
||||||
|
action: currentAction.value,
|
||||||
|
conversationId: trackedConversationId.value,
|
||||||
|
followUpCount: followUpCount.value,
|
||||||
|
stage: 'follow_up',
|
||||||
|
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
isGenerating.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
requestController.signal.aborted ||
|
||||||
|
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
|
||||||
|
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackGenerationFailure({
|
||||||
|
action: currentAction.value,
|
||||||
|
conversationId: trackedConversationId.value,
|
||||||
|
followUpCount: followUpCount.value,
|
||||||
|
stage: 'follow_up',
|
||||||
|
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
|
||||||
|
});
|
||||||
|
isGenerating.value = false;
|
||||||
|
} finally {
|
||||||
|
if (abortController.value === requestController) {
|
||||||
|
abortController.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,11 @@ export const PORTALS_EVENTS = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const CAPTAIN_EVENTS = Object.freeze({
|
export const CAPTAIN_EVENTS = Object.freeze({
|
||||||
|
// Editor funnel events
|
||||||
|
EDITOR_AI_MENU_OPENED: 'Captain: Editor AI menu opened',
|
||||||
|
GENERATION_FAILED: 'Captain: Generation failed',
|
||||||
|
AI_ASSISTED_MESSAGE_SENT: 'Captain: AI-assisted message sent',
|
||||||
|
|
||||||
// Rewrite events (with operation attribute in payload)
|
// Rewrite events (with operation attribute in payload)
|
||||||
REWRITE_USED: 'Captain: Rewrite used',
|
REWRITE_USED: 'Captain: Rewrite used',
|
||||||
REWRITE_APPLIED: 'Captain: Rewrite applied',
|
REWRITE_APPLIED: 'Captain: Rewrite applied',
|
||||||
|
|||||||
Reference in New Issue
Block a user