feat: Reply Editor AI Changes

This commit is contained in:
iamsivin
2025-10-01 21:09:59 +05:30
parent ecff66146a
commit ca34fcd6af
16 changed files with 946 additions and 24 deletions

View File

@@ -94,6 +94,19 @@
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--violet-1: 253 252 254;
--violet-2: 250 248 255;
--violet-3: 244 240 254;
--violet-4: 235 228 255;
--violet-5: 225 217 255;
--violet-6: 212 202 254;
--violet-7: 194 178 248;
--violet-8: 169 153 236;
--violet-9: 110 86 207;
--violet-10: 100 84 196;
--violet-11: 101 85 183;
--violet-12: 47 38 95;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
@@ -209,6 +222,19 @@
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--violet-1: 20 17 31;
--violet-2: 27 21 37;
--violet-3: 41 31 67;
--violet-4: 50 37 85;
--violet-5: 60 46 105;
--violet-6: 71 56 135;
--violet-7: 86 70 151;
--violet-8: 110 86 171;
--violet-9: 110 86 207;
--violet-10: 125 109 217;
--violet-11: 169 153 236;
--violet-12: 226 221 254;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;

View File

@@ -0,0 +1,235 @@
<script setup>
import { ref, computed, watch, onMounted, useTemplateRef } from 'vue';
import {
messageSchema,
buildEditor,
EditorView,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
modelValue: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: {
type: String,
default: 'Give copilot additional prompts, or ask anything else...',
},
enabledMenuOptions: { type: Array, default: () => [] },
generatedContent: { type: String, default: '' },
autofocus: {
type: Boolean,
default: true,
},
});
const emit = defineEmits([
'blur',
'input',
'update:modelValue',
'keyup',
'focus',
'keydown',
'send',
]);
const { formatMessage } = useMessageFormatter();
const createState = (
content,
placeholder,
plugins = [],
methods = {},
enabledMenuOptions = []
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
plugins: buildEditor({
schema: messageSchema,
placeholder,
methods,
plugins,
enabledMenuOptions,
}),
});
};
// we don't need them to be reactive
// It cases weird issues where the objects are proxied
// and then the editor doesn't work as expected
let editorView = null;
let state = null;
// reactive data
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
// element refs
const editor = useTemplateRef('editor');
function contentFromEditor() {
if (editorView) {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
}
return '';
}
function focusEditorInputField() {
const { tr } = editorView.state;
const selection = Selection.atEnd(tr.doc);
editorView.dispatch(tr.setSelection(selection));
editorView.focus();
}
function emitOnChange() {
emit('update:modelValue', contentFromEditor());
emit('input', contentFromEditor());
}
function onKeyup() {
emit('keyup');
}
function onKeydown() {
emit('keydown');
}
function onBlur() {
emit('blur');
}
function onFocus() {
emit('focus');
}
function checkSelection(editorState) {
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
}
// computed properties
const plugins = computed(() => {
return [];
});
function reloadState() {
state = createState(
props.modelValue,
props.placeholder,
plugins.value,
props.enabledMenuOptions
);
editorView.updateState(state);
focusEditorInputField();
}
function createEditorView() {
editorView = new EditorView(editor.value, {
state: state,
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
if (tx.docChanged) {
emitOnChange();
}
checkSelection(state);
},
handleDOMEvents: {
keyup: onKeyup,
focus: onFocus,
blur: onBlur,
keydown: onKeydown,
},
});
}
function handleSubmit() {
emit('send');
}
// watchers
watch(
computed(() => props.modelValue),
(newValue = '') => {
if (newValue !== contentFromEditor()) {
reloadState();
}
}
);
watch(
computed(() => props.editorId),
() => {
reloadState();
}
);
// lifecycle
onMounted(() => {
state = createState(
props.modelValue,
props.placeholder,
plugins.value,
props.enabledMenuOptions
);
createEditorView();
editorView.updateState(state);
if (props.autofocus) {
focusEditorInputField();
}
});
</script>
<template>
<div class="space-y-2">
<div class="editor-root relative editor--copilot space-x-2">
<div ref="editor" />
<div class="flex items-center justify-end absolute right-2 bottom-2">
<NextButton
class="bg-n-iris-9 text-white !rounded-full"
icon="i-lucide-arrow-up"
solid
sm
@click="handleSubmit"
/>
</div>
</div>
<div class="max-h-72 overflow-y-auto">
<p
v-dompurify-html="formatMessage(generatedContent, false)"
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4 underline decoration-n-iris-8 underline-offset-auto decoration-solid decoration-[10%]"
/>
</div>
</div>
</template>
<style lang="scss">
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
.editor--copilot {
@apply bg-n-iris-5 rounded;
.ProseMirror-woot-style {
min-height: 5rem;
max-height: 7.5rem;
overflow: auto;
@apply px-2 !important;
.empty-node {
&::before {
@apply text-n-iris-9;
}
}
}
}
</style>

View File

@@ -0,0 +1,233 @@
<script setup>
import { computed, ref, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useElementSize } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import { useAI } from 'dashboard/composables/useAI';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
hasSelection: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['executeAction']);
const { t } = useI18n();
const { draftMessage } = useAI();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
if (props.hasSelection) {
items.push({
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY_SELECTION'
),
key: 'rephrase_selection',
icon: 'i-fluent-pen-sparkle-24-regular',
});
} else if (
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
draftMessage.value
) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
key: 'rephrase',
icon: 'i-fluent-pen-sparkle-24-regular',
});
}
if (draftMessage.value) {
items.push(
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.TITLE'
),
key: 'change_tone',
icon: 'i-fluent-sound-wave-circle-sparkle-24-regular',
subMenuItems: [
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.FRIENDLY'
),
key: 'make_friendly',
icon: 'i-fluent-person-voice-16-regular',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.FORMAL'
),
key: 'make_formal',
icon: 'i-fluent-person-voice-16-regular',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.SIMPLIFY'
),
key: 'simplify',
icon: 'i-fluent-person-voice-16-regular',
},
],
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.GRAMMAR'),
key: 'fix_spelling_grammar',
icon: 'i-fluent-flow-sparkle-24-regular',
}
);
}
return items;
});
const generalMenuItems = computed(() => {
const items = [];
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
key: 'reply_suggestion',
icon: 'i-fluent-chat-sparkle-16-regular',
});
}
items.push(
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize',
icon: 'i-fluent-text-bullet-list-square-sparkle-32-regular',
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.ASK_COPILOT'),
key: 'ask_copilot',
icon: 'i-fluent-circle-sparkle-24-regular',
}
);
return items;
});
const menuRef = useTemplateRef('menuRef');
// Track expanded submenu items
const expandedItems = ref(new Set());
const { height: menuHeight } = useElementSize(menuRef);
// Computed style for selection menu positioning
const selectionMenuStyle = computed(() => {
// Use the same CSS custom properties as the editor menubar
// Dynamically calculate offset based on actual menu height + 10px gap
const dynamicOffset = menuHeight.value > 0 ? menuHeight.value + 10 : 60;
return {
left: 'var(--selection-left)',
top: `calc(var(--selection-top) - ${dynamicOffset}px)`,
transform: 'translateX(-62%)',
};
});
const handleMenuItemClick = item => {
if (item.subMenuItems) {
// Toggle submenu expansion
if (expandedItems.value.has(item.key)) {
expandedItems.value.delete(item.key);
} else {
expandedItems.value.add(item.key);
}
} else {
emit('executeAction', item.key);
}
};
const handleSubMenuItemClick = (parentItem, subItem) => {
emit('executeAction', subItem.key, {
parentKey: parentItem.key,
tone: subItem.label.toLowerCase(),
});
};
</script>
<template>
<DropdownBody
ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:style="hasSelection ? selectionMenuStyle : {}"
>
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
<div v-for="item in menuItems" :key="item.key" class="w-full">
<Button
:label="item.label"
:icon="item.icon"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
@click="handleMenuItemClick(item)"
>
<template v-if="item.subMenuItems" #default>
<div class="flex items-center gap-1 justify-between w-full">
<span class="min-w-0 truncate">{{ item.label }}</span>
<Icon
:icon="
expandedItems.has(item.key)
? 'i-lucide-chevron-up'
: 'i-lucide-chevron-down'
"
class="text-n-slate-10 size-3 transition-all duration-300 ease-in-out"
/>
</div>
</template>
</Button>
<!-- Sliding Submenu -->
<div
v-if="item.subMenuItems"
class="overflow-hidden transition-all duration-300 ease-in-out"
:class="
expandedItems.has(item.key)
? 'max-h-96 opacity-100'
: 'max-h-0 opacity-0'
"
>
<div class="ltr:pl-5 rtl:pr-5 pt-2 flex flex-col items-start gap-2">
<Button
v-for="subItem in item.subMenuItems"
:key="subItem.key + subItem.label"
:label="subItem.label"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs"
@click="handleSubMenuItemClick(item, subItem)"
/>
</div>
</div>
</div>
</div>
<div v-if="menuItems.length > 0" class="h-px w-full bg-n-strong" />
<div class="flex flex-col items-start gap-3">
<Button
v-for="(item, index) in generalMenuItems"
:key="index"
:label="item.label"
:icon="item.icon"
slate
link
sm
class="hover:!no-underline text-n-slate-12 font-normal text-xs"
@click="handleMenuItemClick(item)"
/>
</div>
</DropdownBody>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['submit', 'cancel']);
const handleCancel = () => {
emit('cancel');
};
const handleSubmit = () => {
emit('submit');
};
</script>
<template>
<div class="flex justify-between items-center p-3 border-t border-n-weak">
<NextButton
label="Discard"
slate
link
class="!px-1 hover:!no-underline"
sm
@click="handleCancel"
/>
<NextButton
label="Accept"
class="bg-n-iris-9 text-white"
solid
sm
@click="handleSubmit"
/>
</div>
</template>

View File

@@ -16,6 +16,7 @@ import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import TagAgents from '../conversation/TagAgents.vue';
import VariableList from '../conversation/VariableList.vue';
import TagTools from '../conversation/TagTools.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
import { useEmitter } from 'dashboard/composables/emitter';
import { useI18n } from 'vue-i18n';
@@ -23,11 +24,12 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { vOnClickOutside } from '@vueuse/components';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
MESSAGE_EDITOR_MENU_OPTIONS,
MESSAGE_EDITOR_MENU_OPTIONS_WITHOUT_COPILLOT,
MESSAGE_EDITOR_IMAGE_RESIZES,
} from 'dashboard/constants/editor';
@@ -97,6 +99,7 @@ const emit = defineEmits([
'focus',
'input',
'update:modelValue',
'executeAction',
]);
const { t } = useI18n();
@@ -153,6 +156,8 @@ const range = ref(null);
const isImageNodeSelected = ref(false);
const toolbarPosition = ref({ top: 0, left: 0 });
const selectedImageNode = ref(null);
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
const showSelectionMenu = ref(false);
const sizes = MESSAGE_EDITOR_IMAGE_RESIZES;
// element ref
@@ -160,6 +165,21 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor');
const handleCopilotAction = actionKey => {
if (actionKey === 'rephrase_selection' && editorView?.state) {
const { from, to } = editorView.state.selection;
const selectedText = editorView.state.doc.textBetween(from, to).trim();
if (from !== to && selectedText) {
emit('executeAction', 'rephrase', selectedText);
}
} else {
emit('executeAction', actionKey);
}
showSelectionMenu.value = false;
};
const contentFromEditor = () => {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
};
@@ -177,7 +197,7 @@ const shouldShowCannedResponses = computed(() => {
const editorMenuOptions = computed(() => {
return props.enabledMenuOptions.length
? props.enabledMenuOptions
: MESSAGE_EDITOR_MENU_OPTIONS;
: MESSAGE_EDITOR_MENU_OPTIONS_WITHOUT_COPILLOT;
});
function createSuggestionPlugin({
@@ -334,13 +354,23 @@ function openFileBrowser() {
imageUpload.value.click();
}
function handleCopilotClick() {
showSelectionMenu.value = !showSelectionMenu.value;
}
function handleClickOutside(event) {
// Check if the clicked element or its parents have the ignored class
if (event.target.closest('.ProseMirror-copilot')) return;
showSelectionMenu.value = false;
}
function reloadState(content = props.modelValue) {
const unrefContent = unref(content);
state = createState(
unrefContent,
props.placeholder,
plugins.value,
{ onImageUpload: openFileBrowser },
{ onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -391,6 +421,54 @@ function setToolbarPosition() {
};
}
function setMenubarPosition(editorState) {
if (!editorState?.selection) return;
const { from, to } = editorState.selection;
const wrapper = editorRoot.value;
if (!wrapper) return;
const {
left: editorLeft,
top: editorTop,
width: editorWidth,
} = wrapper.getBoundingClientRect();
const start = editorView.coordsAtPos(from);
const end = editorView.coordsAtPos(to);
// Calculate selection center and top
const selCenterX =
(Math.min(start.left, end.left) + Math.max(start.right, end.right)) / 2;
const selTop = Math.min(start.top, end.top);
// Clamp center position to keep menubar within editor bounds (with translateX(-50%))
const menubarWidth = 560;
const clampedCenterX = Math.max(
editorLeft + menubarWidth / 4,
Math.min(selCenterX, editorLeft + editorWidth - menubarWidth / 4)
);
// Set CSS custom properties for editor menubar
wrapper.style.setProperty(
'--selection-left',
`${clampedCenterX - editorLeft}px`
);
wrapper.style.setProperty('--selection-top', `${selTop - editorTop - 50}px`);
}
function checkSelection(editorState) {
showSelectionMenu.value = false;
const hasSelection = editorState.selection.from !== editorState.selection.to;
if (hasSelection === isTextSelected.value) return;
isTextSelected.value = hasSelection;
const wrapper = editorRoot.value;
if (!wrapper) return;
wrapper.classList.toggle('has-selection', hasSelection);
if (hasSelection) setMenubarPosition(editorState);
}
function setURLWithQueryAndImageSize(size) {
if (!props.showImageResizeToolbar) {
return;
@@ -587,6 +665,7 @@ function createEditorView() {
if (tx.docChanged) {
emitOnChange();
}
checkSelection(state);
},
handleDOMEvents: {
keyup: () => {
@@ -719,6 +798,14 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
:search-key="toolSearchKey"
@select-tool="content => insertSpecialContent('tool', content)"
/>
<CopilotMenuBar
v-if="showSelectionMenu"
v-on-click-outside="handleClickOutside"
:has-selection="isTextSelected"
:show-selection-menu="showSelectionMenu"
:show-general-menu="false"
@execute-action="handleCopilotAction"
/>
<input
ref="imageUpload"
type="file"

View File

@@ -282,7 +282,10 @@ export default {
</script>
<template>
<div class="flex justify-between p-3" :class="wrapClass">
<div
class="flex justify-between items-center p-3 border-t border-n-weak"
:class="wrapClass"
>
<div class="left-wrap">
<NextButton
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"

View File

@@ -1,14 +1,21 @@
<script>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { vOnClickOutside } from '@vueuse/components';
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue';
import EditorModeToggle from './EditorModeToggle.vue';
import CopilotMenuBar from './CopilotMenuBar.vue';
export default {
name: 'ReplyTopPanel',
components: {
NextButton,
EditorModeToggle,
CopilotMenuBar,
},
directives: {
OnClickOutside: vOnClickOutside,
},
props: {
mode: {
@@ -28,7 +35,7 @@ export default {
default: () => 0,
},
},
emits: ['setReplyMode', 'togglePopout'],
emits: ['setReplyMode', 'togglePopout', 'executeAction'],
setup(props, { emit }) {
const setReplyMode = mode => {
emit('setReplyMode', mode);
@@ -47,6 +54,22 @@ export default {
: REPLY_EDITOR_MODES.REPLY;
setReplyMode(newMode);
};
const showCopilotMenu = ref(false);
const handleCopilotAction = actionKey => {
emit('executeAction', actionKey);
showCopilotMenu.value = false;
};
const toggleCopilotMenu = () => {
showCopilotMenu.value = !showCopilotMenu.value;
};
const handleClickOutside = () => {
showCopilotMenu.value = false;
};
const keyboardEvents = {
'Alt+KeyP': {
action: () => handleNoteClick(),
@@ -64,6 +87,10 @@ export default {
handleReplyClick,
handleNoteClick,
REPLY_EDITOR_MODES,
handleCopilotAction,
showCopilotMenu,
toggleCopilotMenu,
handleClickOutside,
};
},
computed: {
@@ -90,11 +117,12 @@ export default {
</script>
<template>
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
<div
class="flex justify-between gap-2 h-[3.25rem] items-center ltr:pl-3 ltr:pr-2 rtl:pr-3 rtl:pl-2"
>
<EditorModeToggle
:mode="mode"
:disabled="isReplyRestricted"
class="mt-3"
@toggle-mode="handleModeToggle"
/>
<div class="flex items-center mx-4 my-0">
@@ -104,11 +132,33 @@ export default {
</span>
</div>
</div>
<NextButton
ghost
class="ltr:rounded-bl-md rtl:rounded-br-md ltr:rounded-br-none rtl:rounded-bl-none ltr:rounded-tl-none rtl:rounded-tr-none text-n-slate-11 ltr:rounded-tr-[11px] rtl:rounded-tl-[11px]"
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>
<div class="flex items-center gap-2">
<div class="relative">
<NextButton
ghost
:class="{
'text-n-violet-9 hover:!bg-n-violet-3': !showCopilotMenu,
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
}"
sm
icon="i-ph-sparkle-fill"
@click="toggleCopilotMenu"
/>
<CopilotMenuBar
v-if="showCopilotMenu"
v-on-click-outside="handleClickOutside"
:has-selection="false"
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
@execute-action="handleCopilotAction"
/>
</div>
<NextButton
ghost
class="text-n-slate-11"
sm
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,88 @@
<script setup>
import { computed } from 'vue';
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
showCopilotEditor: {
type: Boolean,
default: false,
},
isGeneratingContent: {
type: Boolean,
default: false,
},
copilotEditorContent: {
type: String,
default: '',
},
generatedContent: {
type: String,
default: '',
},
updateEditorSelectionWith: {
type: String,
default: '',
},
});
const emit = defineEmits([
'update:copilotEditorContent',
'focus',
'blur',
'clearSelection',
]);
const copilotContent = computed({
get: () => props.copilotEditorContent,
set: value => emit('update:copilotEditorContent', value),
});
const onFocus = () => {
emit('focus');
};
const onBlur = () => {
emit('blur');
};
const clearEditorSelection = () => {
emit('clearSelection');
};
</script>
<template>
<CopilotEditor
v-if="showCopilotEditor && !isGeneratingContent"
v-model="copilotContent"
class="copilot-editor"
:generated-content="generatedContent"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
:enabled-menu-options="[]"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
/>
<div
v-else-if="isGeneratingContent"
class="bg-n-iris-5 rounded min-h-28 w-full mb-4 p-4 flex items-start"
>
<div class="flex items-center gap-2">
<Icon
icon="i-fluent-bubble-multiple-20-filled"
class="text-n-iris-10 size-4 animate-spin"
/>
<!-- eslint-disable-next-line vue/no-bare-strings-in-template -->
<span class="text-sm text-n-iris-9"> Copilot is thinking </span>
</div>
</div>
</template>
<style lang="scss">
.copilot-editor {
.ProseMirror-menubar {
display: none;
}
}
</style>

View File

@@ -14,7 +14,9 @@ import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.v
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
import CopilotEditorSection from './CopilotEditorSection.vue';
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
import ReplyBoxBanner from './ReplyBoxBanner.vue';
import QuotedEmailPreview from './QuotedEmailPreview.vue';
@@ -48,7 +50,9 @@ import {
replaceSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { MESSAGE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useAI } from 'dashboard/composables/useAI';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { emitter } from 'shared/helpers/mitt';
@@ -74,6 +78,8 @@ export default {
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
CopilotEditorSection,
CopilotReplyBottomPanel,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -95,6 +101,9 @@ export default {
const replyEditor = useTemplateRef('replyEditor');
const { formatMessage } = useMessageFormatter();
const { draftMessage, processEvent, recordAnalytics } = useAI();
return {
uiSettings,
updateUISettings,
@@ -103,6 +112,10 @@ export default {
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
replyEditor,
draftMessage,
processEvent,
recordAnalytics,
formatMessage,
};
},
data() {
@@ -134,6 +147,11 @@ export default {
newConversationModalActive: false,
showArticleSearchPopover: false,
hasRecordedAudio: false,
editorMenuOptions: MESSAGE_EDITOR_MENU_OPTIONS,
showCopilotEditor: false,
isGeneratingContent: false,
copilotEditorContent: '',
generatedContent: '',
};
},
computed: {
@@ -891,6 +909,21 @@ export default {
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
},
async executeAIAction(action, data) {
if (action === 'ask_copilot') {
this.updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
});
} else {
this.isGeneratingContent = true;
// For full message operations
const content = await this.processEvent(action, data);
this.generatedContent = content;
if (content) this.toggleCopilotEditor();
this.isGeneratingContent = false;
}
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
@@ -1155,6 +1188,13 @@ export default {
togglePopout() {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
},
onSubmitCopilotReply() {
this.message = this.generatedContent;
this.showCopilotEditor = false;
},
toggleCopilotEditor() {
this.showCopilotEditor = !this.showCopilotEditor;
},
},
};
</script>
@@ -1170,6 +1210,8 @@ export default {
:popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode"
@toggle-popout="togglePopout"
@toggle-copilot="toggleCopilotEditor"
@execute-action="executeAIAction"
/>
<ArticleSearchPopover
v-if="showArticleSearchPopover && connectedPortalSlug"
@@ -1228,11 +1270,22 @@ export default {
@focus="onFocus"
@blur="onBlur"
/>
<CopilotEditorSection
v-else-if="showCopilotEditor || isGeneratingContent"
v-model:copilot-editor-content="copilotEditorContent"
:show-copilot-editor="showCopilotEditor"
:is-generating-content="isGeneratingContent"
:generated-content="generatedContent"
:update-editor-selection-with="updateEditorSelectionWith"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
/>
<WootMessageEditor
v-else
v-model="message"
:editor-id="editorStateId"
class="input"
class="input reply-editor"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
@@ -1242,6 +1295,7 @@ export default {
:signature="signatureToApply"
allow-signature
:channel-type="channelType"
:enabled-menu-options="editorMenuOptions"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@@ -1250,6 +1304,7 @@ export default {
@toggle-canned-menu="toggleCannedMenu"
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
@execute-action="executeAIAction"
/>
<QuotedEmailPreview
v-if="shouldShowQuotedPreview"
@@ -1272,7 +1327,13 @@ export default {
<MessageSignatureMissingAlert
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
/>
<CopilotReplyBottomPanel
v-if="showCopilotEditor"
@submit="onSubmitCopilotReply"
@cancel="toggleCopilotEditor"
/>
<ReplyBottomPanel
v-else
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:enable-whats-app-templates="showWhatsappTemplates"
@@ -1331,7 +1392,7 @@ export default {
</div>
</template>
<style lang="scss" scoped>
<style lang="scss">
.send-button {
@apply mb-0;
}
@@ -1384,4 +1445,54 @@ export default {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
.reply-editor {
position: relative;
.ProseMirror p:first-child {
margin-top: 0 !important;
}
.ProseMirror p:last-child {
margin-bottom: 10px !important;
}
// Editor Menu
.ProseMirror-menubar {
display: none; // Hide by default
}
&.has-selection {
.ProseMirror-menubar {
@apply rounded-lg !px-3 !py-2 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
left: var(--selection-left);
top: var(--selection-top);
transform: translateX(-50%);
width: fit-content !important;
position: absolute !important;
.ProseMirror-menuitem {
@apply mr-0 size-3.5 flex items-center;
.ProseMirror-icon {
@apply p-0 flex-shrink-0;
svg {
width: 14px !important;
height: 14px !important;
}
}
.ProseMirror-copilot svg {
fill: #6e56cf;
}
}
.ProseMirror-menu-active {
@apply bg-n-slate-3;
}
}
}
}
</style>

View File

@@ -158,14 +158,15 @@ export function useAI() {
/**
* Processes an AI event, such as rephrasing content.
* @param {string} [type='rephrase'] - The type of AI event to process.
* @param {string} [content=''] - The content to process (for full message) or selected text (for selection-based).
* @returns {Promise<string>} The generated message or an empty string if an error occurs.
*/
const processEvent = async (type = 'rephrase') => {
const processEvent = async (type = 'rephrase', content = '') => {
try {
const result = await OpenAPI.processEvent({
hookId: hookId.value,
type,
content: draftMessage.value,
content: content || draftMessage.value,
conversationId: conversationId.value,
});
const {

View File

@@ -1,4 +1,16 @@
export const MESSAGE_EDITOR_MENU_OPTIONS = [
'copilot',
'strong',
'em',
'link',
'undo',
'redo',
'bulletList',
'orderedList',
'code',
];
export const MESSAGE_EDITOR_MENU_OPTIONS_WITHOUT_COPILLOT = [
'strong',
'em',
'link',

View File

@@ -143,6 +143,22 @@
"MAKE_FORMAL": "Use formal tone",
"SIMPLIFY": "Simplify"
},
"REPLY_OPTIONS": {
"IMPROVE_REPLY": "Improve reply",
"IMPROVE_REPLY_SELECTION": "Improve the selection",
"CHANGE_TONE": {
"TITLE": "Change tone",
"OPTIONS": {
"FRIENDLY": "Friendly",
"FORMAL": "Formal",
"SIMPLIFY": "Simplify"
}
},
"GRAMMAR": "Fix grammar & spelling",
"SUGGESTION": "Suggest a reply",
"SUMMARIZE": "Summarize the conversation",
"ASK_COPILOT": "Ask Copilot"
},
"ASSISTANCE_MODAL": {
"DRAFT_TITLE": "Draft content",
"GENERATED_TITLE": "Generated content",

View File

@@ -33,7 +33,7 @@
"dependencies": {
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.2.1",
"@chatwoot/prosemirror-schema": "1.2.2",
"@chatwoot/utils": "^0.0.51",
"@formkit/core": "^1.6.7",
"@formkit/vue": "^1.6.7",
@@ -109,6 +109,7 @@
"devDependencies": {
"@egoist/tailwindcss-icons": "^1.8.1",
"@histoire/plugin-vue": "0.17.15",
"@iconify-json/fluent": "^1.2.32",
"@iconify-json/logos": "^1.2.3",
"@iconify-json/lucide": "^1.2.11",
"@iconify-json/ph": "^1.2.1",

20
pnpm-lock.yaml generated
View File

@@ -20,8 +20,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
specifier: 1.2.1
version: 1.2.1
specifier: 1.2.2
version: 1.2.2
'@chatwoot/utils':
specifier: ^0.0.51
version: 0.0.51
@@ -242,6 +242,9 @@ importers:
'@histoire/plugin-vue':
specifier: 0.17.15
version: 0.17.15(histoire@0.17.15(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0)))(vite@5.4.20(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
'@iconify-json/fluent':
specifier: ^1.2.32
version: 1.2.32
'@iconify-json/logos':
specifier: ^1.2.3
version: 1.2.3
@@ -406,8 +409,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
'@chatwoot/prosemirror-schema@1.2.1':
resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==}
'@chatwoot/prosemirror-schema@1.2.2':
resolution: {integrity: sha512-9knTH6OgZJ5qhJjS70Qiy0VsQwDAte6+gz+2PO1BM3RE+wUknkVS5YAcjqs1IZ4dSZyPrvNOOZh9mXoHdmtgnA==}
'@chatwoot/utils@0.0.51':
resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
@@ -881,6 +884,9 @@ packages:
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
deprecated: Use @eslint/object-schema instead
'@iconify-json/fluent@1.2.32':
resolution: {integrity: sha512-YQFHsRrBhgrfvuVWJTg+EsVDA3pN4QwrFApZ2Di7J6YQIsNVNnBiEpGdT5qX4KxXFre3Yd4D7zm6EsWfKj4QBw==}
'@iconify-json/logos@1.2.3':
resolution: {integrity: sha512-JLHS5hgZP1b55EONAWNeqBUuriRfRNKWXK4cqYx0PpVaJfIIMiiMxFfvoQiX/bkE9XgkLhcKmDUqL3LXPdXPwQ==}
@@ -4763,7 +4769,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
'@chatwoot/prosemirror-schema@1.2.1':
'@chatwoot/prosemirror-schema@1.2.2':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
@@ -5261,6 +5267,10 @@ snapshots:
'@humanwhocodes/object-schema@2.0.3': {}
'@iconify-json/fluent@1.2.32':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/logos@1.2.3':
dependencies:
'@iconify/types': 2.0.0

View File

@@ -258,6 +258,7 @@ const tailwindConfig = {
'ph',
'material-symbols',
'teenyicons',
'fluent',
]),
},
}),

View File

@@ -210,6 +210,21 @@ export const colors = {
12: 'rgb(var(--gray-12) / <alpha-value>)',
},
violet: {
1: 'rgb(var(--violet-1) / <alpha-value>)',
2: 'rgb(var(--violet-2) / <alpha-value>)',
3: 'rgb(var(--violet-3) / <alpha-value>)',
4: 'rgb(var(--violet-4) / <alpha-value>)',
5: 'rgb(var(--violet-5) / <alpha-value>)',
6: 'rgb(var(--violet-6) / <alpha-value>)',
7: 'rgb(var(--violet-7) / <alpha-value>)',
8: 'rgb(var(--violet-8) / <alpha-value>)',
9: 'rgb(var(--violet-9) / <alpha-value>)',
10: 'rgb(var(--violet-10) / <alpha-value>)',
11: 'rgb(var(--violet-11) / <alpha-value>)',
12: 'rgb(var(--violet-12) / <alpha-value>)',
},
black: '#000000',
brand: '#2781F6',
background: 'rgb(var(--background-color) / <alpha-value>)',