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 { 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 {
|
||||
@@ -86,6 +89,7 @@ const props = defineProps({
|
||||
// are triggered except when this flag is true
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
conversationId: { type: Number, default: null },
|
||||
medium: { type: String, default: '' },
|
||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||
focusOnMount: { type: Boolean, default: true },
|
||||
@@ -396,7 +400,14 @@ function openFileBrowser() {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import { ref } from 'vue';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
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 EditorModeToggle from './EditorModeToggle.vue';
|
||||
import CopilotMenuBar from './CopilotMenuBar.vue';
|
||||
@@ -31,6 +33,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
isMessageLengthReachingThreshold: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
@@ -69,7 +75,14 @@ export default {
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
|
||||
@@ -41,7 +41,10 @@ import {
|
||||
truncatePreviewText,
|
||||
appendQuotedTextToMessage,
|
||||
} 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 {
|
||||
appendSignature,
|
||||
@@ -136,6 +139,7 @@ export default {
|
||||
newConversationModalActive: false,
|
||||
showArticleSearchPopover: false,
|
||||
hasRecordedAudio: false,
|
||||
copilotAcceptedMessages: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -508,6 +512,24 @@ export default {
|
||||
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
|
||||
},
|
||||
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) {
|
||||
const { url, title } = article;
|
||||
// Removing empty lines from the title
|
||||
@@ -559,7 +581,7 @@ export default {
|
||||
},
|
||||
saveDraft(conversationId, replyType) {
|
||||
if (this.message || this.message === '') {
|
||||
const key = `draft-${conversationId}-${replyType}`;
|
||||
const key = this.getDraftKey(conversationId, replyType);
|
||||
const draftToSave = trimContent(this.message || '');
|
||||
|
||||
this.$store.dispatch('draftMessages/set', {
|
||||
@@ -574,7 +596,7 @@ export default {
|
||||
},
|
||||
getFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
const key = this.getDraftKey();
|
||||
const messageFromStore =
|
||||
this.$store.getters['draftMessages/get'](key) || '';
|
||||
|
||||
@@ -597,7 +619,7 @@ export default {
|
||||
},
|
||||
removeFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
const key = this.getDraftKey();
|
||||
this.$store.dispatch('draftMessages/delete', { key });
|
||||
}
|
||||
},
|
||||
@@ -708,6 +730,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
if (!this.showMentions) {
|
||||
const copilotAcceptedMessage = this.getCopilotAcceptedMessage();
|
||||
const isOnWhatsApp =
|
||||
this.isATwilioWhatsAppChannel ||
|
||||
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.
|
||||
const isOnInstagram = this.isAnInstagramChannel;
|
||||
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
|
||||
this.sendMessageAsMultipleMessages(this.message);
|
||||
this.sendMessageAsMultipleMessages(
|
||||
this.message,
|
||||
copilotAcceptedMessage
|
||||
);
|
||||
} else {
|
||||
const messagePayload = this.getMessagePayload(this.message);
|
||||
this.sendMessage(messagePayload);
|
||||
this.sendMessage(
|
||||
messagePayload,
|
||||
this.message,
|
||||
copilotAcceptedMessage
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.isPrivate) {
|
||||
@@ -732,13 +762,53 @@ export default {
|
||||
this.$emit('update:popOutReplyBox', false);
|
||||
}
|
||||
},
|
||||
sendMessageAsMultipleMessages(message) {
|
||||
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
|
||||
const messages = this.getMultipleMessagesPayload(message);
|
||||
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
|
||||
return isPrivate
|
||||
? useTrack(CONVERSATION_EVENTS.SENT_PRIVATE_NOTE)
|
||||
@@ -772,7 +842,11 @@ export default {
|
||||
this.confirmOnSendReply();
|
||||
}
|
||||
},
|
||||
async sendMessage(messagePayload) {
|
||||
async sendMessage(
|
||||
messagePayload,
|
||||
editorMessage = '',
|
||||
copilotAcceptedMessage = ''
|
||||
) {
|
||||
try {
|
||||
await this.$store.dispatch(
|
||||
'createPendingMessageAndSend',
|
||||
@@ -781,7 +855,10 @@ export default {
|
||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
|
||||
this.removeFromDraft();
|
||||
this.sendMessageAnalyticsData(messagePayload.private);
|
||||
this.sendMessageAnalyticsData(messagePayload.private, {
|
||||
editorMessage,
|
||||
copilotAcceptedMessage,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
|
||||
@@ -855,6 +932,7 @@ export default {
|
||||
},
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
this.clearCopilotAcceptedMessage();
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
// if signature is enabled, append it to the message
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
@@ -1119,7 +1197,9 @@ export default {
|
||||
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
|
||||
},
|
||||
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">
|
||||
<ReplyTopPanel
|
||||
:mode="replyType"
|
||||
:conversation-id="conversationId"
|
||||
:is-reply-restricted="isReplyRestricted"
|
||||
:disabled="
|
||||
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
|
||||
@@ -1204,6 +1285,7 @@ export default {
|
||||
<WootMessageEditor
|
||||
v-else-if="!showAudioRecorderEditor"
|
||||
v-model="message"
|
||||
:conversation-id="conversationId"
|
||||
:editor-id="editorStateId"
|
||||
class="input popover-prosemirror-menu"
|
||||
: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 { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
import { CAPTAIN_ERROR_TYPES } from 'dashboard/composables/captain/constants';
|
||||
|
||||
export function useCaptain() {
|
||||
const store = useStore();
|
||||
@@ -69,7 +70,10 @@ export function useCaptain() {
|
||||
* @param {Error} error - The error object from the API call.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
const errorMessage =
|
||||
@@ -78,6 +82,24 @@ export function useCaptain() {
|
||||
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 ===
|
||||
/**
|
||||
* Rewrites content with a specific operation.
|
||||
@@ -103,7 +125,7 @@ export function useCaptain() {
|
||||
return { message: generatedMessage, followUpContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '' };
|
||||
return { message: '', errorType: getErrorType(error) };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,7 +147,7 @@ export function useCaptain() {
|
||||
return { message: generatedMessage, followUpContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '' };
|
||||
return { message: '', errorType: getErrorType(error) };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,7 +169,7 @@ export function useCaptain() {
|
||||
return { message: generatedMessage, followUpContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '' };
|
||||
return { message: '', errorType: getErrorType(error) };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -171,7 +193,11 @@ export function useCaptain() {
|
||||
return { message: generatedMessage, followUpContext: updatedContext };
|
||||
} catch (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 { useTrack } from 'dashboard/composables';
|
||||
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)
|
||||
const REWRITE_ACTIONS = [
|
||||
@@ -52,6 +56,20 @@ function buildPayload(action, conversationId, followUpCount = undefined) {
|
||||
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.
|
||||
* 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(false);
|
||||
abortController.value = new AbortController();
|
||||
const requestController = new AbortController();
|
||||
abortController.value = requestController;
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
currentAction.value = action;
|
||||
@@ -154,28 +173,66 @@ export function useCopilotReply() {
|
||||
trackedConversationId.value = conversationId.value;
|
||||
|
||||
try {
|
||||
const { message: content, followUpContext: newContext } =
|
||||
await processEvent(action, data, {
|
||||
signal: abortController.value.signal,
|
||||
});
|
||||
const {
|
||||
message: content,
|
||||
followUpContext: newContext,
|
||||
errorType,
|
||||
} = await processEvent(action, data, {
|
||||
signal: requestController.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)
|
||||
);
|
||||
if (requestController.signal.aborted) return;
|
||||
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||
if (abortController.value === requestController) {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
isGenerating.value = false;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
isGenerating.value = false;
|
||||
|
||||
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)
|
||||
);
|
||||
} 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) {
|
||||
if (!followUpContext.value || !message.trim()) return;
|
||||
|
||||
abortController.value = new AbortController();
|
||||
const requestController = new AbortController();
|
||||
abortController.value = requestController;
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
|
||||
@@ -198,24 +256,65 @@ export function useCopilotReply() {
|
||||
followUpCount.value += 1;
|
||||
|
||||
try {
|
||||
const { message: content, followUpContext: updatedContext } =
|
||||
await followUp({
|
||||
followUpContext: followUpContext.value,
|
||||
message,
|
||||
signal: abortController.value.signal,
|
||||
});
|
||||
const {
|
||||
message: content,
|
||||
followUpContext: updatedContext,
|
||||
errorType,
|
||||
} = await followUp({
|
||||
followUpContext: followUpContext.value,
|
||||
message,
|
||||
signal: requestController.signal,
|
||||
});
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
if (content) {
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = updatedContext;
|
||||
showEditor.value = true;
|
||||
if (requestController.signal.aborted) return;
|
||||
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
|
||||
if (abortController.value === requestController) {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
isGenerating.value = false;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
isGenerating.value = false;
|
||||
|
||||
if (content) {
|
||||
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({
|
||||
// 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_USED: 'Captain: Rewrite used',
|
||||
REWRITE_APPLIED: 'Captain: Rewrite applied',
|
||||
|
||||
Reference in New Issue
Block a user