feat: reply editor follow-up (#13106)

Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
This commit is contained in:
Shivam Mishra
2026-01-13 14:29:11 +05:30
committed by GitHub
co-authored by aakashb95
parent 7f057127b0
commit 82f5dbe6c1
21 changed files with 507 additions and 186 deletions
@@ -108,6 +108,27 @@ class TasksAPI extends ApiClient {
{ signal }
);
}
/**
* Sends a follow-up message to continue refining a previous task result.
* @param {Object} options - The follow-up options.
* @param {Object} options.followUpContext - The follow-up context from a previous task.
* @param {string} options.message - The follow-up message/request from the user.
* @param {string} [options.conversationId] - The conversation ID for Langfuse session tracking.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context.
*/
followUp({ followUpContext, message, conversationId }, signal) {
return axios.post(
`${this.url}/follow_up`,
{
follow_up_context: followUpContext,
message,
conversation_display_id: conversationId,
},
{ signal }
);
}
}
export default new TasksAPI();
@@ -50,7 +50,8 @@ export default {
async generateAIContent(type = 'improve') {
this.isGenerating = true;
this.generatedContent = await this.processEvent(type);
const { message } = await this.processEvent(type);
this.generatedContent = message;
this.isGenerating = false;
},
applyText() {
@@ -10,7 +10,7 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
defineProps({
hasSelection: {
type: Boolean,
default: false,
@@ -28,7 +28,12 @@ const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
if (props.hasSelection) {
// for now, we don't allow improving just aprt of the selection
// we will add this feature later. Once we do, we can revert the change
const hasSelection = false;
// const hasSelection = props.hasSelection
if (hasSelection) {
items.push({
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY_SELECTION'
@@ -1,35 +1,37 @@
<script setup>
import { computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
import { useKbd } from 'dashboard/composables/utils/useKbd';
defineProps({
isGeneratingContent: {
type: Boolean,
default: false,
},
isPrivate: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const handleCancel = () => {
emit('cancel');
};
const shortcutKey = useKbd(['$mod', '+', 'enter']);
const acceptLabel = computed(() => {
return `${t('GENERAL.ACCEPT')} (${shortcutKey.value})`;
});
const handleSubmit = () => {
emit('submit');
};
</script>
<template>
<div
class="flex justify-between items-center p-3 border-t"
:class="{ 'border-n-weak': !isPrivate, 'border-n-amber-12/5': isPrivate }"
>
<div class="flex justify-between items-center p-3 pt-0">
<NextButton
label="Discard"
:label="t('GENERAL.DISCARD')"
slate
link
class="!px-1 hover:!no-underline"
@@ -38,7 +40,7 @@ const handleSubmit = () => {
@click="handleCancel"
/>
<NextButton
label="Accept"
:label="acceptLabel"
class="bg-n-iris-9 text-white"
solid
sm
@@ -56,7 +56,6 @@ import {
getFormattingForEditor,
getSelectionCoords,
calculateMenuPosition,
stripUnsupportedFormatting,
getEffectiveChannelType,
stripUnsupportedFormatting,
} from 'dashboard/helper/editorHelper';
@@ -22,7 +22,13 @@ defineProps({
},
});
const emit = defineEmits(['focus', 'blur', 'clearSelection', 'contentReady']);
const emit = defineEmits([
'focus',
'blur',
'clearSelection',
'contentReady',
'send',
]);
const copilotEditorContent = ref('');
@@ -37,6 +43,11 @@ const onBlur = () => {
const clearEditorSelection = () => {
emit('clearSelection');
};
const onSend = () => {
emit('send', copilotEditorContent.value);
copilotEditorContent.value = '';
};
</script>
<template>
@@ -62,6 +73,7 @@ const clearEditorSelection = () => {
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@send="onSend"
/>
<div
v-else-if="isGeneratingContent"
@@ -48,6 +48,7 @@ import {
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
@@ -94,6 +95,7 @@ export default {
const replyEditor = useTemplateRef('replyEditor');
const copilot = useCopilotReply();
const shortcutKey = useKbd(['$mod', '+', 'enter']);
return {
uiSettings,
@@ -103,6 +105,7 @@ export default {
fetchQuotedReplyFlagFromUISettings,
replyEditor,
copilot,
shortcutKey,
};
},
data() {
@@ -273,7 +276,7 @@ export default {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
}
const keyLabel = this.isEditorHotKeyEnabled('cmd_enter')
? '(⌘ + ↵)'
? `(${this.shortcutKey})`
: '(↵)';
return `${sendMessageText} ${keyLabel}`;
},
@@ -624,7 +627,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();
}
},
@@ -1171,6 +1176,7 @@ export default {
@clear-selection="clearEditorSelection"
@close="copilot.showEditor.value = false"
@content-ready="copilot.setContentReady"
@send="copilot.sendFollowUp"
/>
<WootMessageEditor
v-else-if="!showAudioRecorderEditor"
@@ -1240,7 +1246,6 @@ export default {
<CopilotReplyBottomPanel
v-if="copilot.isActive.value"
key="copilot-bottom-panel"
:is-private="isOnPrivateNote"
:is-generating-content="copilot.isButtonDisabled.value"
@submit="onSubmitCopilotReply"
@cancel="copilot.toggleEditor"
+38 -5
View File
@@ -154,7 +154,7 @@ export function useAI() {
* @param {string} [content=''] - The content to process (for full message) or selected text (for selection-based).
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<string>} The generated message or an empty string if an error occurs.
* @returns {Promise<{message: string, followUpContext?: Object}>} The generated message and optional follow-up context.
*/
const processEvent = async (type = 'improve', content = '', options = {}) => {
try {
@@ -167,20 +167,52 @@ export function useAI() {
options.signal
);
const {
data: { message: generatedMessage },
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return generatedMessage;
return { message: generatedMessage, followUpContext };
} catch (error) {
// Don't show error for aborted requests
if (error.name === 'AbortError' || error.name === 'CanceledError') {
return '';
return { message: '' };
}
const errorData = error.response?.data?.error;
const errorMessage =
errorData?.error?.message ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
return '';
return { message: '' };
}
};
/**
* Sends a follow-up message to refine a previous AI task result.
* @param {Object} options - The follow-up options.
* @param {Object} options.followUpContext - The follow-up context from a previous task.
* @param {string} options.message - The follow-up message/request from the user.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext: Object}>} The follow-up response and updated context.
*/
const followUp = async ({ followUpContext, message, signal }) => {
try {
const result = await TasksAPI.followUp(
{ followUpContext, message, conversationId: conversationId.value },
signal
);
const {
data: { message: generatedMessage, follow_up_context: updatedContext },
} = result;
return { message: generatedMessage, followUpContext: updatedContext };
} catch (error) {
// Don't show error for aborted requests
if (error.name === 'AbortError' || error.name === 'CanceledError') {
return { message: '', followUpContext };
}
const errorData = error.response?.data?.error;
const errorMessage =
errorData?.error?.message ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
return { message: '', followUpContext };
}
};
@@ -201,5 +233,6 @@ export function useAI() {
recordAnalytics,
fetchLabelSuggestions,
processEvent,
followUp,
};
}
@@ -9,13 +9,14 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
* @returns {Object} Copilot reply state and methods
*/
export function useCopilotReply() {
const { processEvent } = useAI();
const { processEvent, followUp } = useAI();
const { updateUISettings } = useUISettings();
const showEditor = ref(false);
const isGenerating = ref(false);
const isContentReady = ref(false);
const generatedContent = ref('');
const followUpContext = ref(null);
const abortController = ref(null);
const isActive = computed(() => showEditor.value || isGenerating.value);
@@ -38,6 +39,7 @@ export function useCopilotReply() {
isGenerating.value = false;
isContentReady.value = false;
generatedContent.value = '';
followUpContext.value = null;
}
/**
@@ -75,12 +77,14 @@ export function useCopilotReply() {
isContentReady.value = false;
try {
const content = await processEvent(action, data, {
signal: abortController.value.signal,
});
const { message: content, followUpContext: newContext } =
await processEvent(action, data, {
signal: abortController.value.signal,
});
if (!abortController.value?.signal.aborted) {
generatedContent.value = content;
followUpContext.value = newContext;
if (content) showEditor.value = true;
isGenerating.value = false;
}
@@ -91,6 +95,40 @@ export function useCopilotReply() {
}
}
/**
* Sends a follow-up message to refine the current generated content.
* @param {string} message - The follow-up message from the user
*/
async function sendFollowUp(message) {
if (!followUpContext.value || !message.trim()) return;
abortController.value = new AbortController();
isGenerating.value = true;
isContentReady.value = false;
try {
const { message: content, followUpContext: updatedContext } =
await followUp({
followUpContext: followUpContext.value,
message,
signal: abortController.value.signal,
});
if (!abortController.value?.signal.aborted) {
if (content) {
generatedContent.value = content;
followUpContext.value = updatedContext;
showEditor.value = true;
}
isGenerating.value = false;
}
} catch {
if (!abortController.value?.signal.aborted) {
isGenerating.value = false;
}
}
}
/**
* Accepts the generated content and returns it.
* Note: Formatting is automatically stripped by the Editor component's
@@ -108,6 +146,7 @@ export function useCopilotReply() {
isGenerating,
isContentReady,
generatedContent,
followUpContext,
isActive,
isButtonDisabled,
@@ -117,6 +156,7 @@ export function useCopilotReply() {
toggleEditor,
setContentReady,
execute,
sendFollowUp,
accept,
};
}
@@ -1,14 +1,25 @@
import { computed } from 'vue';
function isMacOS() {
// Check modern userAgentData API first
if (navigator.userAgentData?.platform) {
return navigator.userAgentData.platform === 'macOS';
}
// Fallback to navigator.platform
return (
navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone'
);
}
export function useKbd(keys) {
const keySymbols = {
$mod: navigator.platform.includes('Mac') ? '⌘' : 'Ctrl',
$mod: isMacOS() ? '⌘' : 'Ctrl',
shift: '⇧',
alt: '⌥',
ctrl: 'Ctrl',
cmd: '⌘',
option: '⌥',
enter: '',
enter: '',
tab: '⇥',
esc: '⎋',
};
@@ -16,7 +27,11 @@ export function useKbd(keys) {
return computed(() => {
return keys
.map(key => keySymbols[key.toLowerCase()] || key)
.join('')
.join(' ')
.toUpperCase();
});
}
export function getModifierKey() {
return isMacOS() ? '⌘' : 'Ctrl';
}
@@ -186,7 +186,7 @@
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else...",
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"CLICK_HERE": "Click here to update",
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
},
@@ -8,6 +8,8 @@
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
"ACCEPT": "Accept",
"DISCARD": "Discard",
"PREFERRED": "Preferred"
}
}