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:
Aakash Bakhle
2026-02-17 13:26:56 +05:30
committed by GitHub
parent 61eaa098ae
commit 101eca3003
7 changed files with 304 additions and 56 deletions

View File

@@ -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) {

View File

@@ -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 = () => {

View File

@@ -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"

View 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',
});

View File

@@ -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),
};
} }
}; };

View File

@@ -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;
} }
} }
} }

View File

@@ -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',