Merge branch 'develop' into fix/missing-postmessage-validation

This commit is contained in:
Shivam Mishra
2026-01-21 13:42:18 +05:30
committed by GitHub
147 changed files with 5745 additions and 2308 deletions
+3 -3
View File
@@ -76,7 +76,7 @@ jobs:
bundle install
- node/install:
node-version: '24.12'
node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -117,7 +117,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '24.12'
node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -148,7 +148,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '24.12'
node-version: '24.13'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
+1 -1
View File
@@ -10,7 +10,7 @@ services:
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.12.0'
NODE_VERSION: '24.13.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
+1 -1
View File
@@ -11,7 +11,7 @@ services:
dockerfile: .devcontainer/Dockerfile
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.12.0'
NODE_VERSION: '24.13.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
+1 -1
View File
@@ -1 +1 @@
24.12.0
24.13.0
+1 -1
View File
@@ -1 +1 @@
4.9.2
4.10.1
@@ -50,3 +50,5 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@current_page = params[:page] || 1
end
end
Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
@@ -49,7 +49,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT]
}
@allowed_configs = mapping.fetch(
+6 -7
View File
@@ -2,13 +2,6 @@
# No need to replicate the same values in two places
# ------- Premium Features ------- #
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-captain'
config_key: 'captain'
enterprise: true
saml:
name: 'SAML SSO'
description: 'Configuration for controlling SAML Single Sign-On availability'
@@ -48,6 +41,12 @@ help_center:
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'icon-book-2-line'
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: true
icon: 'icon-captain'
config_key: 'captain'
# ------- Communication Channels ------- #
live_chat:
@@ -0,0 +1,107 @@
/* global axios */
import ApiClient from '../ApiClient';
/**
* A client for the Captain Tasks API.
* @extends ApiClient
*/
class TasksAPI extends ApiClient {
/**
* Creates a new TasksAPI instance.
*/
constructor() {
super('captain/tasks', { accountScoped: true });
}
/**
* Rewrites content with a specific operation.
* @param {Object} options - The rewrite options.
* @param {string} options.content - The content to rewrite.
* @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc).
* @param {string} [options.conversationId] - The conversation ID for context (required for 'improve').
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the rewritten content.
*/
rewrite({ content, operation, conversationId }, signal) {
return axios.post(
`${this.url}/rewrite`,
{
content,
operation,
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Summarizes a conversation.
* @param {string} conversationId - The conversation ID to summarize.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the summary.
*/
summarize(conversationId, signal) {
return axios.post(
`${this.url}/summarize`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Gets a reply suggestion for a conversation.
* @param {string} conversationId - The conversation ID.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the reply suggestion.
*/
replySuggestion(conversationId, signal) {
return axios.post(
`${this.url}/reply_suggestion`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Gets label suggestions for a conversation.
* @param {string} conversationId - The conversation ID.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with label suggestions.
*/
labelSuggestion(conversationId, signal) {
return axios.post(
`${this.url}/label_suggestion`,
{
conversation_display_id: conversationId,
},
{ 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();
@@ -1,81 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
/**
* Represents the data object for a OpenAI hook.
* @typedef {Object} ConversationMessageData
* @property {string} [tone] - The tone of the message.
* @property {string} [content] - The content of the message.
* @property {string} [conversation_display_id] - The display ID of the conversation (optional).
*/
/**
* A client for the OpenAI API.
* @extends ApiClient
*/
class OpenAIAPI extends ApiClient {
/**
* Creates a new OpenAIAPI instance.
*/
constructor() {
super('integrations', { accountScoped: true });
/**
* The conversation events supported by the API.
* @type {string[]}
*/
this.conversation_events = [
'summarize',
'reply_suggestion',
'label_suggestion',
];
/**
* The message events supported by the API.
* @type {string[]}
*/
this.message_events = ['rephrase'];
}
/**
* Processes an event using the OpenAI API.
* @param {Object} options - The options for the event.
* @param {string} [options.type='rephrase'] - The type of event to process.
* @param {string} [options.content] - The content of the event.
* @param {string} [options.tone] - The tone of the event.
* @param {string} [options.conversationId] - The ID of the conversation to process the event for.
* @param {string} options.hookId - The ID of the hook to use for processing the event.
* @returns {Promise} A promise that resolves with the result of the event processing.
*/
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
/**
* @type {ConversationMessageData}
*/
let data = {
tone,
content,
};
// Always include conversation_display_id when available for session tracking
if (conversationId) {
data.conversation_display_id = conversationId;
}
// For conversation-level events, only send conversation_display_id
if (this.conversation_events.includes(type)) {
data = {
conversation_display_id: conversationId,
};
}
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
event: {
name: type,
data,
},
});
}
}
export default new OpenAIAPI();
@@ -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;
@@ -15,7 +15,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
@@ -661,7 +660,6 @@ const menuItems = computed(() => {
<div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
/>
<YearInReviewBanner />
<SidebarChangelogCard
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
/>
@@ -1,13 +1,11 @@
<script setup>
import { ref, computed } from 'vue';
import { computed } from 'vue';
import Auth from 'dashboard/api/auth';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Avatar from 'next/avatar/Avatar.vue';
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
import {
DropdownContainer,
@@ -24,7 +22,6 @@ defineOptions({
});
const { t } = useI18n();
const { uiSettings } = useUISettings();
const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
@@ -34,29 +31,6 @@ const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showYearInReviewModal = ref(false);
const bannerClosedKey = computed(() => {
return `yir_closed_${accountId.value}_2025`;
});
const isBannerClosed = computed(() => {
return uiSettings.value?.[bannerClosedKey.value] === true;
});
const showYearInReviewMenuItem = computed(() => {
return isBannerClosed.value;
});
const openYearInReviewModal = () => {
showYearInReviewModal.value = true;
emit('close');
};
const closeYearInReviewModal = () => {
showYearInReviewModal.value = false;
};
const showChatSupport = computed(() => {
return (
isFeatureEnabledonAccount.value(
@@ -68,13 +42,6 @@ const showChatSupport = computed(() => {
const menuItems = computed(() => {
return [
{
show: showYearInReviewMenuItem.value,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
icon: 'i-lucide-gift',
click: openYearInReviewModal,
},
{
show: showChatSupport.value,
showOnCustomBrandedInstance: false,
@@ -190,9 +157,4 @@ const allowedMenuItems = computed(() => {
</template>
</DropdownBody>
</DropdownContainer>
<YearInReviewModal
:show="showYearInReviewModal"
@close="closeYearInReviewModal"
/>
</template>
@@ -206,10 +206,14 @@ const emitDateRange = () => {
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
}
};
const closeDatePicker = () => {
showDatePicker.value = false;
};
</script>
<template>
<div class="relative font-inter">
<div v-on-clickaway="closeDatePicker" class="relative font-inter">
<DatePickerButton
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
@@ -1,160 +0,0 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import AICTAModal from './AICTAModal.vue';
import AIAssistanceModal from './AIAssistanceModal.vue';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
AIAssistanceModal,
AICTAModal,
AIAssistanceCTAButton,
},
emits: ['replaceText'],
setup(props, { emit }) {
const { uiSettings, updateUISettings } = useUISettings();
const { isAIIntegrationEnabled, draftMessage, recordAnalytics } = useAI();
const { isAdmin } = useAdmin();
const initialMessage = ref('');
const initializeMessage = draftMsg => {
initialMessage.value = draftMsg;
};
const keyboardEvents = {
'$mod+KeyZ': {
action: () => {
if (initialMessage.value) {
emit('replaceText', initialMessage.value);
initialMessage.value = '';
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
uiSettings,
updateUISettings,
isAdmin,
initialMessage,
initializeMessage,
recordAnalytics,
isAIIntegrationEnabled,
draftMessage,
};
},
data: () => ({
showAIAssistanceModal: false,
showAICtaModal: false,
aiOption: '',
}),
computed: {
...mapGetters({
isAChatwootInstance: 'globalConfig/isAChatwootInstance',
}),
isAICTAModalDismissed() {
return this.uiSettings.is_open_ai_cta_modal_dismissed;
},
// Display a AI CTA button for admins if the AI integration has not been added yet and the AI assistance modal has not been dismissed.
shouldShowAIAssistCTAButtonForAdmin() {
return (
this.isAdmin &&
!this.isAIIntegrationEnabled &&
!this.isAICTAModalDismissed &&
this.isAChatwootInstance
);
},
// Display a AI CTA button for agents and other admins who have not yet opened the AI assistance modal.
shouldShowAIAssistCTAButton() {
return this.isAIIntegrationEnabled && !this.isAICTAModalDismissed;
},
},
mounted() {
emitter.on(CMD_AI_ASSIST, this.onAIAssist);
this.initializeMessage(this.draftMessage);
},
methods: {
hideAIAssistanceModal() {
this.recordAnalytics('DISMISS_AI_SUGGESTION', {
aiOption: this.aiOption,
});
this.showAIAssistanceModal = false;
},
openAIAssist() {
// Dismiss the CTA modal if it is not dismissed
if (!this.isAICTAModalDismissed) {
this.updateUISettings({
is_open_ai_cta_modal_dismissed: true,
});
}
this.initializeMessage(this.draftMessage);
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'ai_assist' });
},
hideAICtaModal() {
this.showAICtaModal = false;
},
openAICta() {
this.showAICtaModal = true;
},
onAIAssist(option) {
this.aiOption = option;
this.showAIAssistanceModal = true;
},
insertText(message) {
this.$emit('replaceText', message);
},
},
};
</script>
<template>
<div>
<div v-if="isAIIntegrationEnabled" class="relative">
<AIAssistanceCTAButton
v-if="shouldShowAIAssistCTAButton"
@open="openAIAssist"
/>
<NextButton
v-else
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
icon="i-ph-magic-wand"
slate
faded
sm
@click="openAIAssist"
/>
<woot-modal
v-model:show="showAIAssistanceModal"
:on-close="hideAIAssistanceModal"
>
<AIAssistanceModal
:ai-option="aiOption"
@apply-text="insertText"
@close="hideAIAssistanceModal"
/>
</woot-modal>
</div>
<div v-else-if="shouldShowAIAssistCTAButtonForAdmin" class="relative">
<AIAssistanceCTAButton @click="openAICta" />
<woot-modal v-model:show="showAICtaModal" :on-close="hideAICtaModal">
<AICTAModal @close="hideAICtaModal" />
</woot-modal>
</div>
</div>
</template>
@@ -1,103 +0,0 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['open']);
const onClick = () => {
emit('open');
};
</script>
<template>
<div class="relative">
<NextButton
class="cta-btn cta-btn-light dark:cta-btn-dark hover:cta-btn-light-hover dark:hover:cta-btn-dark-hover"
:label="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
icon="i-ph-magic-wand"
sm
@click="onClick"
/>
<div
class="radar-ping-animation absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-n-brand"
/>
<div
class="absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-n-brand opacity-50"
/>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
/* Gradient animation */
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.cta-btn {
animation: gradient 5s ease infinite;
@apply text-n-slate-12 border-0 text-xs;
}
.cta-btn-light {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #f2f5f8, #f2f5f8);
}
.cta-btn-dark {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #313538, #313538);
}
.cta-btn-light-hover {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #e3e5e7, #e3e5e7);
}
.cta-btn-dark-hover {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #202425, #202425);
}
/* Radar ping animation */
@keyframes ping {
75%,
100% {
transform: scale(2);
opacity: 0;
}
}
.radar-ping-animation {
animation: ping 1s ease infinite;
}
}
</style>
@@ -1,118 +0,0 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useAI } from 'dashboard/composables/useAI';
import AILoader from './AILoader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
AILoader,
NextButton,
},
props: {
aiOption: {
type: String,
required: true,
},
},
emits: ['close', 'applyText'],
setup() {
const { formatMessage } = useMessageFormatter();
const { draftMessage, processEvent, recordAnalytics } = useAI();
return { draftMessage, processEvent, recordAnalytics, formatMessage };
},
data() {
return {
generatedContent: '',
isGenerating: true,
};
},
computed: {
headerTitle() {
const translationKey = this.aiOption?.toUpperCase();
return translationKey
? this.$t(`INTEGRATION_SETTINGS.OPEN_AI.WITH_AI`, {
option: this.$t(
`INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.${translationKey}`
),
})
: '';
},
},
mounted() {
this.generateAIContent(this.aiOption);
},
methods: {
onClose() {
this.$emit('close');
},
async generateAIContent(type = 'rephrase') {
this.isGenerating = true;
this.generatedContent = await this.processEvent(type);
this.isGenerating = false;
},
applyText() {
this.recordAnalytics(this.aiOption);
this.$emit('applyText', this.generatedContent);
this.onClose();
},
},
};
</script>
<template>
<div class="flex flex-col">
<woot-modal-header :header-title="headerTitle" />
<form
class="flex flex-col w-full modal-content"
@submit.prevent="applyText"
>
<div v-if="draftMessage" class="w-full">
<h4 class="mt-1 text-base text-n-slate-12">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.DRAFT_TITLE') }}
</h4>
<p v-dompurify-html="formatMessage(draftMessage, false)" />
<h4 class="mt-1 text-base text-n-slate-12">
{{
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.GENERATED_TITLE')
}}
</h4>
</div>
<div>
<AILoader v-if="isGenerating" />
<p v-else v-dompurify-html="formatMessage(generatedContent, false)" />
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.CANCEL')
"
@click.prevent="onClose"
/>
<NextButton
type="submit"
:disabled="!generatedContent"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.APPLY')
"
/>
</div>
</form>
</div>
</template>
<style lang="scss" scoped>
.modal-content {
@apply pt-2 px-8 pb-8;
}
.container {
width: 100%;
}
</style>
@@ -1,130 +0,0 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAI } from 'dashboard/composables/useAI';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['close'],
setup() {
const { updateUISettings } = useUISettings();
const { recordAnalytics } = useAI();
const v$ = useVuelidate();
return { updateUISettings, v$, recordAnalytics };
},
data() {
return {
value: '',
};
},
validations: {
value: {
required,
},
},
methods: {
onClose() {
this.$emit('close');
},
onDismiss() {
useAlert(
this.$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.DISMISS_MESSAGE')
);
this.updateUISettings({
is_open_ai_cta_modal_dismissed: true,
});
this.onClose();
},
async finishOpenAI() {
const payload = {
app_id: 'openai',
settings: {
api_key: this.value,
},
};
try {
await this.$store.dispatch('integrations/createHook', payload);
this.alertMessage = this.$t(
'INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.SUCCESS_MESSAGE'
);
this.recordAnalytics(
OPEN_AI_EVENTS.ADDED_AI_INTEGRATION_VIA_CTA_BUTTON
);
this.onClose();
} catch (error) {
const errorMessage = error?.response?.data?.message;
this.alertMessage =
errorMessage || this.$t('INTEGRATION_APPS.ADD.API.ERROR_MESSAGE');
} finally {
useAlert(this.alertMessage);
}
},
openOpenAIDoc() {
window.open('https://www.chatwoot.com/blog/v2-17', '_blank');
},
},
};
</script>
<template>
<div class="flex-1 min-w-0 px-0">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.TITLE')"
:header-content="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.DESC')"
/>
<form
class="flex flex-col flex-wrap modal-content"
@submit.prevent="finishOpenAI"
>
<div class="w-full mt-2">
<woot-input
v-model="value"
type="text"
:class="{ error: v$.value.$error }"
:placeholder="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.KEY_PLACEHOLDER')
"
@blur="v$.value.$touch"
/>
</div>
<div class="flex flex-row justify-between w-full gap-2 px-0 py-2">
<NextButton
ghost
type="button"
class="!px-3"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.NEED_HELP')
"
@click.prevent="openOpenAIDoc"
/>
<div class="flex items-center gap-1">
<NextButton
faded
slate
type="reset"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.DISMISS')
"
@click.prevent="onDismiss"
/>
<NextButton
type="submit"
:disabled="v$.value.$invalid"
:label="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.FINISH')"
/>
</div>
</div>
</form>
</div>
</template>
@@ -46,11 +46,11 @@ const fileName = file => {
</script>
<template>
<div class="flex overflow-auto max-h-[12.5rem]">
<div class="flex flex-wrap gap-y-1 gap-x-2 overflow-auto max-h-[12.5rem]">
<div
v-for="(attachment, index) in nonRecordedAudioAttachments"
:key="attachment.id"
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem] mb-1"
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem]"
>
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
<img
@@ -38,7 +38,7 @@ const toggleShowMore = () => {
<button
v-if="text.length > limit"
class="text-n-brand !p-0 !border-0 align-top"
@click="toggleShowMore"
@click.stop="toggleShowMore"
>
{{ buttonLabel }}
</button>
@@ -0,0 +1,253 @@
<script setup>
import { ref, computed, watch, onMounted, useTemplateRef } from 'vue';
import {
buildMessageSchema,
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...',
},
generatedContent: { type: String, default: '' },
autofocus: {
type: Boolean,
default: true,
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'blur',
'input',
'update:modelValue',
'keyup',
'focus',
'keydown',
'send',
]);
const { formatMessage } = useMessageFormatter();
// Minimal schema with no marks or nodes for copilot input
const copilotSchema = buildMessageSchema([], []);
const handleSubmit = () => emit('send');
const createState = (
content,
placeholder,
plugins = [],
enabledMenuOptions = []
) => {
return EditorState.create({
doc: new MessageMarkdownTransformer(copilotSchema).parse(content),
plugins: buildEditor({
schema: copilotSchema,
placeholder,
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(view, event) {
emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line)
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling
}
return false; // Allow other keys to work normally
}
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 [];
});
const enabledMenuOptions = computed(() => {
return [];
});
function reloadState() {
state = createState(
props.modelValue,
props.placeholder,
plugins.value,
enabledMenuOptions.value
);
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,
},
});
}
// 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,
enabledMenuOptions.value
);
createEditorView();
editorView.updateState(state);
if (props.autofocus) {
focusEditorInputField();
}
});
</script>
<template>
<div class="space-y-2 mb-4">
<div
class="overflow-y-auto"
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
>
<p
v-dompurify-html="formatMessage(generatedContent, false)"
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
/>
</div>
<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>
</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 !important;
overflow: auto;
@apply px-2 !important;
.empty-node {
&::before {
@apply text-n-iris-9 dark:text-n-iris-11;
}
}
}
}
</style>
@@ -0,0 +1,259 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useElementSize, useWindowSize } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import { useCaptain } from 'dashboard/composables/useCaptain';
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';
defineProps({
hasSelection: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['executeCopilotAction']);
const { t } = useI18n();
const { draftMessage } = useCaptain();
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
// 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'
),
key: 'improve_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: 'improve',
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.PROFESSIONAL'
),
key: 'professional',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CASUAL'
),
key: 'casual',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.STRAIGHTFORWARD'
),
key: 'straightforward',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CONFIDENT'
),
key: 'confident',
},
{
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.FRIENDLY'
),
key: 'friendly',
},
],
},
{
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',
});
}
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
items.push({
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
key: 'summarize',
icon: 'i-fluent-text-bullet-list-square-sparkle-32-regular',
});
}
items.push({
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');
const { height: menuHeight } = useElementSize(menuRef);
const { width: windowWidth } = useWindowSize();
// Smart submenu positioning based on available space
const submenuPosition = computed(() => {
const el = menuRef.value?.$el;
if (!el) return 'ltr:right-full rtl:left-full';
const { left, right } = el.getBoundingClientRect();
const SUBMENU_WIDTH = 200;
const spaceRight = (windowWidth.value ?? window.innerWidth) - right;
const spaceLeft = left;
// Prefer right, fallback to side with more space
const showRight = spaceRight >= SUBMENU_WIDTH || spaceRight >= spaceLeft;
return showRight ? 'left-full' : 'right-full';
});
// Computed style for selection menu positioning (only dynamic top offset)
const selectionMenuStyle = computed(() => {
// Dynamically calculate offset based on actual menu height + 10px gap
const dynamicOffset = menuHeight.value > 0 ? menuHeight.value + 10 : 60;
return {
top: `calc(var(--selection-top) - ${dynamicOffset}px)`,
};
});
const handleMenuItemClick = item => {
// For items with submenus, do nothing on click (hover will show submenu)
if (!item.subMenuItems) {
emit('executeCopilotAction', item.key);
}
};
const handleSubMenuItemClick = (parentItem, subItem) => {
emit('executeCopilotAction', subItem.key);
};
</script>
<template>
<DropdownBody
ref="menuRef"
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
:class="{ 'selection-menu': hasSelection }"
: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 relative group/submenu"
>
<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="i-lucide-chevron-right"
class="text-n-slate-10 size-3"
/>
</div>
</template>
</Button>
<!-- Hover Submenu -->
<DropdownBody
v-if="item.subMenuItems"
class="group-hover/submenu:block hidden [&>ul]:gap-2 [&>ul]:px-3 [&>ul]:py-2.5 [&>ul]:dark:!border-n-strong max-h-[15rem] min-w-32 z-10 top-0"
:class="submenuPosition"
>
<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 w-full !justify-start mb-1"
@click="handleSubMenuItemClick(item, subItem)"
/>
</DropdownBody>
</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 w-full !justify-start"
@click="handleMenuItemClick(item)"
/>
</div>
</DropdownBody>
</template>
<style scoped lang="scss">
.selection-menu {
position: absolute !important;
// Default/LTR: position from left
left: var(--selection-left);
// RTL: position from right instead
[dir='rtl'] & {
left: auto;
right: var(--selection-right);
}
}
</style>
@@ -0,0 +1,51 @@
<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,
},
});
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 pt-0">
<NextButton
:label="t('GENERAL.DISCARD')"
slate
link
class="!px-1 hover:!no-underline"
sm
:disabled="isGeneratingContent"
@click="handleCancel"
/>
<NextButton
:label="acceptLabel"
class="bg-n-iris-9 text-white"
solid
sm
:disabled="isGeneratingContent"
@click="handleSubmit"
/>
</div>
</template>
@@ -16,13 +16,16 @@ 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';
import { useCaptain } from 'dashboard/composables/useCaptain';
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';
@@ -100,13 +103,16 @@ const emit = defineEmits([
'focus',
'input',
'update:modelValue',
'executeCopilotAction',
]);
const { t } = useI18n();
const { captainTasksEnabled } = useCaptain();
const TYPING_INDICATOR_IDLE_TIME = 4000;
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const DEFAULT_FORMATTING = 'Context::Default';
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
const effectiveChannelType = computed(() =>
getEffectiveChannelType(props.channelType, props.medium)
@@ -116,17 +122,24 @@ const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
const formatType = props.isPrivate
? DEFAULT_FORMATTING
? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value;
const formatting = getFormattingForEditor(formatType);
const formatting = getFormattingForEditor(
formatType,
captainTasksEnabled.value
);
return buildMessageSchema(formatting.marks, formatting.nodes);
});
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
const formatting = getFormattingForEditor(
formatType,
captainTasksEnabled.value
);
return formatting.menu;
});
@@ -185,6 +198,21 @@ const editorRoot = useTemplateRef('editorRoot');
const imageUpload = useTemplateRef('imageUpload');
const editor = useTemplateRef('editor');
const handleCopilotAction = actionKey => {
if (actionKey === 'improve_selection' && editorView?.state) {
const { from, to } = editorView.state.selection;
const selectedText = editorView.state.doc.textBetween(from, to).trim();
if (from !== to && selectedText) {
emit('executeCopilotAction', 'improve', selectedText);
}
} else {
emit('executeCopilotAction', actionKey);
}
showSelectionMenu.value = false;
};
const contentFromEditor = () => {
return MessageMarkdownSerializer.serialize(editorView.state.doc);
};
@@ -367,13 +395,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 },
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -595,7 +633,12 @@ function insertContentIntoEditor(content, defaultFrom = 0) {
const from = defaultFrom || editorView.state.selection.from || 0;
// Use the editor's current schema to ensure compatibility with buildMessageSchema
const currentSchema = editorView.state.schema;
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
// Strip unsupported formatting before parsing to ensure content can be inserted
// into channels that don't support certain markdown features (e.g., API channels)
const sanitizedContent = stripUnsupportedFormatting(content, currentSchema);
let node = new MessageMarkdownTransformer(currentSchema).parse(
sanitizedContent
);
insertNodeIntoEditor(node, from, undefined);
}
@@ -757,7 +800,7 @@ onMounted(() => {
props.modelValue,
props.placeholder,
plugins.value,
{ onImageUpload: openFileBrowser },
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@@ -802,6 +845,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-copilot-action="handleCopilotAction"
/>
<input
ref="imageUpload"
type="file"
@@ -855,6 +906,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply size-full;
}
}
.ProseMirror-copilot svg {
@apply fill-n-violet-9 text-n-violet-9 stroke-none;
}
}
}
@@ -994,6 +1049,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.ProseMirror-icon {
@apply p-0.5 flex-shrink-0;
}
.ProseMirror-copilot svg {
@apply fill-n-violet-9 text-n-violet-9 stroke-none;
}
}
.ProseMirror-menu-active {
@@ -12,6 +12,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
isReplyRestricted: {
type: Boolean,
default: false,
},
});
defineEmits(['toggleMode']);
@@ -24,11 +28,17 @@ const privateModeSize = useElementSize(wootEditorPrivateMode);
/**
* Computed boolean indicating if the editor is in private note mode
* When disabled, always show NOTE mode regardless of actual mode prop
* When isReplyRestricted is true, force switch to private note
* Otherwise, respect the current mode prop
* @type {ComputedRef<boolean>}
*/
const isPrivate = computed(() => {
return props.disabled || props.mode === REPLY_EDITOR_MODES.NOTE;
if (props.isReplyRestricted) {
// Force switch to private note when replies are restricted
return true;
}
// Otherwise respect the current mode
return props.mode === REPLY_EDITOR_MODES.NOTE;
});
/**
@@ -60,9 +70,9 @@ const translateValue = computed(() => {
<template>
<button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
:disabled="disabled"
:disabled="disabled || isReplyRestricted"
:class="{
'cursor-not-allowed': disabled,
'cursor-not-allowed': disabled || isReplyRestricted,
}"
@click="$emit('toggleMode')"
>
@@ -75,7 +85,7 @@ const translateValue = computed(() => {
<div
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
:class="{
'transition-all duration-300': !disabled,
'transition-all duration-300': !disabled && !isReplyRestricted,
}"
:style="{
'--chip-width': width,
@@ -9,14 +9,13 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
name: 'ReplyBottomPanel',
components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
components: { NextButton, FileUpload, VideoCallButton },
mixins: [inboxMixin],
props: {
isNote: {
@@ -98,6 +97,7 @@ export default {
type: Number,
required: true,
},
// eslint-disable-next-line vue/no-unused-properties
message: {
type: String,
default: '',
@@ -370,13 +370,6 @@ export default {
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
:conversation-id="conversationId"
/>
<AIAssistanceButton
v-if="!isFetchingAppIntegrations"
:conversation-id="conversationId"
:is-private-note="isOnPrivateNote"
:message="message"
@replace-text="replaceText"
/>
<transition name="modal-fade">
<div
v-show="uploadRef && uploadRef.dropActive"
@@ -1,14 +1,22 @@
<script>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useCaptain } from 'dashboard/composables/useCaptain';
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: {
@@ -19,6 +27,10 @@ export default {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@@ -28,7 +40,7 @@ export default {
default: () => 0,
},
},
emits: ['setReplyMode', 'togglePopout'],
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) {
const setReplyMode = mode => {
emit('setReplyMode', mode);
@@ -47,6 +59,23 @@ export default {
: REPLY_EDITOR_MODES.REPLY;
setReplyMode(newMode);
};
const { captainTasksEnabled } = useCaptain();
const showCopilotMenu = ref(false);
const handleCopilotAction = actionKey => {
emit('executeCopilotAction', actionKey);
showCopilotMenu.value = false;
};
const toggleCopilotMenu = () => {
showCopilotMenu.value = !showCopilotMenu.value;
};
const handleClickOutside = () => {
showCopilotMenu.value = false;
};
const keyboardEvents = {
'Alt+KeyP': {
action: () => handleNoteClick(),
@@ -64,6 +93,11 @@ export default {
handleReplyClick,
handleNoteClick,
REPLY_EDITOR_MODES,
captainTasksEnabled,
handleCopilotAction,
showCopilotMenu,
toggleCopilotMenu,
handleClickOutside,
};
},
computed: {
@@ -90,11 +124,13 @@ 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"
:disabled="disabled"
:is-reply-restricted="isReplyRestricted"
@toggle-mode="handleModeToggle"
/>
<div class="flex items-center mx-4 my-0">
@@ -104,11 +140,34 @@ 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 v-if="captainTasksEnabled" class="flex items-center gap-2">
<div class="relative">
<NextButton
ghost
:disabled="disabled"
:class="{
'text-n-violet-9 hover:enabled:!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-copilot-action="handleCopilotAction"
/>
</div>
<NextButton
ghost
class="text-n-slate-11"
sm
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
/>
</div>
</div>
</template>
@@ -0,0 +1,99 @@
<script setup>
import { ref } from 'vue';
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
defineProps({
showCopilotEditor: {
type: Boolean,
default: false,
},
isGeneratingContent: {
type: Boolean,
default: false,
},
generatedContent: {
type: String,
default: '',
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'focus',
'blur',
'clearSelection',
'contentReady',
'send',
]);
const copilotEditorContent = ref('');
const onFocus = () => {
emit('focus');
};
const onBlur = () => {
emit('blur');
};
const clearEditorSelection = () => {
emit('clearSelection');
};
const onSend = () => {
emit('send', copilotEditorContent.value);
copilotEditorContent.value = '';
};
</script>
<template>
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
@after-enter="emit('contentReady')"
>
<CopilotEditor
v-if="showCopilotEditor && !isGeneratingContent"
key="copilot-editor"
v-model="copilotEditorContent"
class="copilot-editor"
:generated-content="generatedContent"
:min-height="4"
:enabled-menu-options="[]"
:is-popout="isPopout"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@send="onSend"
/>
<div
v-else-if="isGeneratingContent"
key="loading-state"
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
>
<div class="flex items-center gap-2">
<CaptainLoader class="text-n-iris-10 size-4" />
<span class="text-sm text-n-iris-10">
{{ $t('CONVERSATION.REPLYBOX.COPILOT_THINKING') }}
</span>
</div>
</div>
</Transition>
</template>
<style lang="scss">
.copilot-editor {
.ProseMirror-menubar {
display: none;
}
}
</style>
@@ -11,7 +11,7 @@ const openProfileSettings = () => {
<template>
<div
class="my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
class="my-0 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
>
<p class="w-fit !m-0">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
@@ -1,9 +1,8 @@
<script>
import { ref, provide } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
// components
@@ -49,7 +48,6 @@ export default {
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig();
const keyboardEvents = {
Escape: {
@@ -61,22 +59,14 @@ export default {
useKeyboardEvents(keyboardEvents);
const {
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
const { captainTasksEnabled, getLabelSuggestions } = useCaptain();
provide('contextMenuElementTarget', conversationPanelRef);
return {
isEnterprise,
isPopOutReplyBox,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
captainTasksEnabled,
getLabelSuggestions,
conversationPanelRef,
};
},
@@ -104,10 +94,7 @@ export default {
},
shouldShowLabelSuggestions() {
return (
this.isOpen &&
this.isEnterprise &&
this.isAIIntegrationEnabled &&
!this.messageSentSinceOpened
this.isOpen && this.captainTasksEnabled && !this.messageSentSinceOpened
);
},
inboxId() {
@@ -291,24 +278,15 @@ export default {
return;
}
if (!this.isEnterprise) {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = this.currentChat?.labels || [];
if (existingLabels.length > 0) return;
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
if (!this.isLabelSuggestionFeatureEnabled) {
if (!this.captainTasksEnabled) {
return;
}
this.labelSuggestions = await this.fetchLabelSuggestions({
conversationId: this.currentChat.id,
});
this.labelSuggestions = await this.getLabelSuggestions();
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated
@@ -12,7 +12,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';
@@ -21,6 +23,7 @@ import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vu
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import {
getMessageVariables,
getUndefinedVariablesInMessage,
@@ -45,6 +48,8 @@ import {
removeSignature,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -70,6 +75,8 @@ export default {
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
CopilotEditorSection,
CopilotReplyBottomPanel,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -89,6 +96,8 @@ export default {
} = useUISettings();
const replyEditor = useTemplateRef('replyEditor');
const copilot = useCopilotReply();
const shortcutKey = useKbd(['$mod', '+', 'enter']);
return {
uiSettings,
@@ -97,6 +106,8 @@ export default {
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
replyEditor,
copilot,
shortcutKey,
};
},
data() {
@@ -267,7 +278,7 @@ export default {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
}
const keyLabel = this.isEditorHotKeyEnabled('cmd_enter')
? '(⌘ + ↵)'
? `(${this.shortcutKey})`
: '(↵)';
return `${sendMessageText} ${keyLabel}`;
},
@@ -400,6 +411,9 @@ export default {
!!this.quotedEmailText
);
},
isDefaultEditorMode() {
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -409,6 +423,8 @@ export default {
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
// like self-assign or other updates that do not actually change the conversation context
this.setCCAndToEmailsFromLastChat();
// Reset Copilot editor state (includes cancelling ongoing generation)
this.copilot.reset();
}
if (this.isOnPrivateNote) {
@@ -478,6 +494,7 @@ export default {
this.onNewConversationModalActive
);
emitter.on(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
emitter.on(CMD_AI_ASSIST, this.executeCopilotAction);
},
unmounted() {
document.removeEventListener('paste', this.onPaste);
@@ -488,6 +505,7 @@ export default {
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
},
methods: {
handleInsert(article) {
@@ -613,7 +631,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();
}
},
@@ -830,6 +850,9 @@ export default {
this.updateEditorSelectionWith = content;
this.onFocus();
},
executeCopilotAction(action, data) {
this.copilot.execute(action, data);
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
@@ -1095,6 +1118,9 @@ export default {
togglePopout() {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
},
onSubmitCopilotReply() {
this.message = this.copilot.accept();
},
},
};
</script>
@@ -1105,11 +1131,17 @@ export default {
<ReplyTopPanel
:mode="replyType"
:is-reply-restricted="isReplyRestricted"
:disabled="
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
showAudioRecorderEditor
"
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode"
@toggle-popout="togglePopout"
@toggle-copilot="copilot.toggleEditor"
@execute-copilot-action="executeCopilotAction"
/>
<ArticleSearchPopover
v-if="showArticleSearchPopover && connectedPortalSlug"
@@ -1117,112 +1149,167 @@ export default {
@insert="handleInsert"
@close="onSearchPopoverClose"
/>
<div class="reply-box__top">
<ReplyToMessage
v-if="shouldShowReplyToMessage"
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
}"
:on-click="addIntoEditor"
/>
<ReplyEmailHead
v-if="showReplyHead"
v-model:cc-emails="ccEmails"
v-model:bcc-emails="bccEmails"
v-model:to-emails="toEmails"
/>
<AudioRecorder
v-if="showAudioRecorderEditor"
ref="audioRecorderInput"
:audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder"
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<WootMessageEditor
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
/>
<QuotedEmailPreview
v-if="shouldShowQuotedPreview"
:quoted-email-text="quotedEmailText"
:preview-text="quotedEmailPreviewText"
@toggle="toggleQuotedReply"
/>
</div>
<div
v-if="hasAttachments && !showAudioRecorderEditor"
class="attachment-preview-box"
@paste="onPaste"
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
>
<AttachmentPreview
class="flex-col mt-4"
:attachments="attachedFiles"
@remove-attachment="removeAttachment"
<div :key="copilot.editorTransitionKey.value" class="reply-box__top">
<ReplyToMessage
v-if="shouldShowReplyToMessage"
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
}"
:on-click="addIntoEditor"
/>
<ReplyEmailHead
v-if="showReplyHead && isDefaultEditorMode"
v-model:cc-emails="ccEmails"
v-model:bcc-emails="bccEmails"
v-model:to-emails="toEmails"
/>
<AudioRecorder
v-if="showAudioRecorderEditor"
ref="audioRecorderInput"
:audio-record-format="audioRecordFormat"
@recorder-progress-changed="onRecordProgressChanged"
@finish-record="onFinishRecorder"
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<CopilotEditorSection
v-if="copilot.isActive.value && !showAudioRecorderEditor"
:show-copilot-editor="copilot.showEditor.value"
:is-generating-content="copilot.isGenerating.value"
:generated-content="copilot.generatedContent.value"
:is-popout="popOutReplyBox"
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@close="copilot.showEditor.value = false"
@content-ready="copilot.setContentReady"
@send="copilot.sendFollowUp"
/>
<WootMessageEditor
v-else-if="!showAudioRecorderEditor"
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
:update-selection-with="updateEditorSelectionWith"
:min-height="4"
enable-variables
:variables="messageVariables"
:signature="messageSignature"
allow-signature
:channel-type="channelType"
:medium="inbox.medium"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
@execute-copilot-action="executeCopilotAction"
/>
<QuotedEmailPreview
v-if="shouldShowQuotedPreview && isDefaultEditorMode"
:quoted-email-text="quotedEmailText"
:preview-text="quotedEmailPreviewText"
class="mb-2"
@toggle="toggleQuotedReply"
/>
<div
v-if="hasAttachments && isDefaultEditorMode"
class="bg-transparent py-0 mb-2"
@paste="onPaste"
>
<AttachmentPreview
class="mt-2"
:attachments="attachedFiles"
@remove-attachment="removeAttachment"
/>
</div>
<MessageSignatureMissingAlert
v-if="
isSignatureEnabledForInbox &&
!isSignatureAvailable &&
isDefaultEditorMode
"
class="mb-2"
/>
</div>
</Transition>
<Transition
mode="out-in"
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
>
<CopilotReplyBottomPanel
v-if="copilot.isActive.value"
key="copilot-bottom-panel"
:is-generating-content="copilot.isButtonDisabled.value"
@submit="onSubmitCopilotReply"
@cancel="copilot.toggleEditor"
/>
</div>
<MessageSignatureMissingAlert
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
/>
<ReplyBottomPanel
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:enable-whats-app-templates="showWhatsappTemplates"
:enable-content-templates="showContentTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
:quoted-reply-enabled="quotedReplyPreference"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:message="message"
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
<ReplyBottomPanel
v-else
key="reply-bottom-panel"
:conversation-id="conversationId"
:enable-multiple-file-upload="enableMultipleFileUpload"
:enable-whats-app-templates="showWhatsappTemplates"
:enable-content-templates="showContentTemplates"
:inbox="inbox"
:is-on-private-note="isOnPrivateNote"
:is-recording-audio="isRecordingAudio"
:is-send-disabled="isReplyButtonDisabled"
:is-note="isPrivate"
:on-file-upload="onFileUpload"
:on-send="onSendReply"
:conversation-type="conversationType"
:recording-audio-duration-text="recordingAudioDurationText"
:recording-audio-state="recordingAudioState"
:send-button-text="replyButtonLabel"
:show-audio-recorder="showAudioRecorder"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
:quoted-reply-enabled="quotedReplyPreference"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
:message="message"
:portal-slug="connectedPortalSlug"
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
</Transition>
<WhatsappTemplates
:inbox-id="inbox.id"
:show="showWhatsAppTemplatesModal"
@@ -1252,13 +1339,7 @@ export default {
@apply mb-0;
}
.attachment-preview-box {
@apply bg-transparent py-0 px-4;
}
.reply-box {
transition: height 2s cubic-bezier(0.37, 0, 0.63, 1);
@apply relative mb-2 mx-2 border border-n-weak rounded-xl bg-n-solid-1;
&.is-private {
@@ -4,7 +4,7 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
// composables
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
// store & api
@@ -33,9 +33,9 @@ export default {
},
},
setup() {
const { isAIIntegrationEnabled } = useAI();
const { captainTasksEnabled } = useCaptain();
return { isAIIntegrationEnabled };
return { captainTasksEnabled };
},
data() {
return {
@@ -78,7 +78,7 @@ export default {
},
shouldShowSuggestions() {
if (this.isDismissed) return false;
if (!this.isAIIntegrationEnabled) return false;
if (!this.captainTasksEnabled) return false;
return this.preparedLabels.length && this.chatLabels.length === 0;
},
@@ -0,0 +1,33 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
</script>
<template>
<Icon v-once icon="i-woot-captain" class="jumping-logo" />
</template>
<style scoped>
.jumping-logo {
transform-origin: center bottom;
animation: jump 1s cubic-bezier(0.28, 0.84, 0.42, 1) infinite;
will-change: transform;
}
@keyframes jump {
0% {
transform: translateY(0) scale(1, 1);
}
20% {
transform: translateY(0) scale(1.05, 0.95);
}
50% {
transform: translateY(-5px) scale(0.95, 1.05);
}
80% {
transform: translateY(0) scale(1.02, 0.98);
}
100% {
transform: translateY(0) scale(1, 1);
}
}
</style>
@@ -3,7 +3,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import {
@@ -18,7 +18,7 @@ vi.mock('dashboard/composables/store');
vi.mock('vue-i18n');
vi.mock('vue-router');
vi.mock('dashboard/composables/useConversationLabels');
vi.mock('dashboard/composables/useAI');
vi.mock('dashboard/composables/useCaptain');
vi.mock('dashboard/composables/useAgentsList');
describe('useConversationHotKeys', () => {
@@ -49,7 +49,7 @@ describe('useConversationHotKeys', () => {
addLabelToConversation: vi.fn(),
removeLabelFromConversation: vi.fn(),
});
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: true } });
useCaptain.mockReturnValue({ captainTasksEnabled: { value: true } });
useAgentsList.mockReturnValue({
agentsList: { value: [] },
assignableAgents: { value: mockAssignableAgents },
@@ -67,7 +67,7 @@ describe('useConversationHotKeys', () => {
expect(conversationHotKeys.value.length).toBeGreaterThan(0);
});
it('should include AI assist actions when AI integration is enabled', () => {
it('should include AI assist actions when captain tasks is enabled', () => {
const { conversationHotKeys } = useConversationHotKeys();
const aiAssistAction = conversationHotKeys.value.find(
action => action.id === 'ai_assist'
@@ -75,8 +75,8 @@ describe('useConversationHotKeys', () => {
expect(aiAssistAction).toBeDefined();
});
it('should not include AI assist actions when AI integration is disabled', () => {
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: false } });
it('should not include AI assist actions when captain tasks is disabled', () => {
useCaptain.mockReturnValue({ captainTasksEnabled: { value: false } });
const { conversationHotKeys } = useConversationHotKeys();
const aiAssistAction = conversationHotKeys.value.find(
action => action.id === 'ai_assist'
@@ -4,7 +4,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { emitter } from 'shared/helpers/mitt';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
@@ -102,8 +102,8 @@ const createNonDraftMessageAIAssistActions = (t, replyMode) => {
const createDraftMessageAIAssistActions = t => {
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
key: 'rephrase',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CONFIDENT'),
key: 'confident',
icon: ICON_AI_ASSIST,
},
{
@@ -112,28 +112,23 @@ const createDraftMessageAIAssistActions = t => {
icon: ICON_AI_GRAMMAR,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
key: 'expand',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.PROFESSIONAL'),
key: 'professional',
icon: ICON_AI_EXPAND,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
key: 'shorten',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CASUAL'),
key: 'casual',
icon: ICON_AI_SHORTEN,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
key: 'make_friendly',
key: 'friendly',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FORMAL'),
key: 'make_formal',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
key: 'simplify',
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.STRAIGHTFORWARD'),
key: 'straightforward',
icon: ICON_AI_ASSIST,
},
];
@@ -151,7 +146,7 @@ export function useConversationHotKeys() {
removeLabelFromConversation,
} = useConversationLabels();
const { isAIIntegrationEnabled } = useAI();
const { captainTasksEnabled } = useCaptain();
const { agentsList } = useAgentsList();
const currentChat = useMapGetter('getSelectedChat');
@@ -386,7 +381,7 @@ export function useConversationHotKeys() {
...labelActions.value,
...assignPriorityActions.value,
];
if (isAIIntegrationEnabled.value) {
if (captainTasksEnabled.value) {
return [...defaultConversationHotKeys, ...AIAssistActions.value];
}
return defaultConversationHotKeys;
@@ -1,122 +0,0 @@
import { useAI } from '../useAI';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import OpenAPI from 'dashboard/api/integrations/openapi';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
vi.mock('dashboard/composables/store');
vi.mock('vue-i18n');
vi.mock('dashboard/api/integrations/openapi');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
};
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
OPEN_AI_EVENTS: {
TEST_EVENT: 'open_ai_test_event',
},
}));
describe('useAI', () => {
const mockStore = {
dispatch: vi.fn(),
};
const mockGetters = {
'integrations/getUIFlags': { value: { isFetching: false } },
'draftMessages/get': { value: () => 'Draft message' },
};
beforeEach(() => {
vi.clearAllMocks();
useStore.mockReturnValue(mockStore);
useStoreGetters.mockReturnValue(mockGetters);
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [],
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
useI18n.mockReturnValue({ t: vi.fn() });
});
it('initializes computed properties correctly', async () => {
const { uiFlags, appIntegrations, currentChat, replyMode, draftMessage } =
useAI();
expect(uiFlags.value).toEqual({ isFetching: false });
expect(appIntegrations.value).toEqual([]);
expect(currentChat.value).toEqual({ id: '123' });
expect(replyMode.value).toBe('reply');
expect(draftMessage.value).toBe('Draft message');
});
it('fetches integrations if required', async () => {
const { fetchIntegrationsIfRequired } = useAI();
await fetchIntegrationsIfRequired();
expect(mockStore.dispatch).toHaveBeenCalledWith('integrations/get');
});
it('does not fetch integrations if already loaded', async () => {
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [{ id: 'openai' }],
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
const { fetchIntegrationsIfRequired } = useAI();
await fetchIntegrationsIfRequired();
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
it('records analytics correctly', async () => {
// const mockTrack = analyticsHelper.track;
const { recordAnalytics } = useAI();
await recordAnalytics('TEST_EVENT', { data: 'test' });
expect(analyticsHelper.track).toHaveBeenCalledWith('open_ai_test_event', {
type: 'TEST_EVENT',
data: 'test',
});
});
it('fetches label suggestions', async () => {
OpenAPI.processEvent.mockResolvedValue({
data: { message: 'label1, label2' },
});
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [
{ id: 'openai', hooks: [{ id: 'hook1' }] },
],
getSelectedChat: { id: '123' },
};
return { value: mockValues[getter] };
});
const { fetchLabelSuggestions } = useAI();
const result = await fetchLabelSuggestions();
expect(OpenAPI.processEvent).toHaveBeenCalledWith({
type: 'label_suggestion',
hookId: 'hook1',
conversationId: '123',
});
expect(result).toEqual(['label1', 'label2']);
});
});
@@ -0,0 +1,213 @@
import { useCaptain } from '../useCaptain';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useI18n } from 'vue-i18n';
import TasksAPI from 'dashboard/api/captain/tasks';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useAccount');
vi.mock('dashboard/composables/useConfig');
vi.mock('vue-i18n');
vi.mock('dashboard/api/captain/tasks');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
};
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
OPEN_AI_EVENTS: {
TEST_EVENT: 'open_ai_test_event',
},
}));
describe('useCaptain', () => {
const mockStore = {
dispatch: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
useStore.mockReturnValue(mockStore);
useFunctionGetter.mockReturnValue({ value: 'Draft message' });
useMapGetter.mockImplementation(getter => {
const mockValues = {
'accounts/getUIFlags': { isFetchingLimits: false },
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
useI18n.mockReturnValue({ t: vi.fn() });
useAccount.mockReturnValue({
isCloudFeatureEnabled: vi.fn().mockReturnValue(true),
currentAccount: { value: { limits: { captain: {} } } },
});
useConfig.mockReturnValue({
isEnterprise: false,
});
});
it('initializes computed properties correctly', async () => {
const { captainEnabled, captainTasksEnabled, currentChat, draftMessage } =
useCaptain();
expect(captainEnabled.value).toBe(true);
expect(captainTasksEnabled.value).toBe(true);
expect(currentChat.value).toEqual({ id: '123' });
expect(draftMessage.value).toBe('Draft message');
});
it('records analytics correctly', async () => {
const { recordAnalytics } = useCaptain();
await recordAnalytics('TEST_EVENT', { data: 'test' });
expect(analyticsHelper.track).toHaveBeenCalledWith('open_ai_test_event', {
type: 'TEST_EVENT',
data: 'test',
});
});
it('gets label suggestions', async () => {
TasksAPI.labelSuggestion.mockResolvedValue({
data: { message: 'label1, label2' },
});
const { getLabelSuggestions } = useCaptain();
const result = await getLabelSuggestions();
expect(TasksAPI.labelSuggestion).toHaveBeenCalledWith('123');
expect(result).toEqual(['label1', 'label2']);
});
it('rewrites content', async () => {
TasksAPI.rewrite.mockResolvedValue({
data: { message: 'Rewritten content', follow_up_context: { id: 'ctx1' } },
});
const { rewriteContent } = useCaptain();
const result = await rewriteContent('Original content', 'improve', {});
expect(TasksAPI.rewrite).toHaveBeenCalledWith(
{
content: 'Original content',
operation: 'improve',
conversationId: '123',
},
undefined
);
expect(result).toEqual({
message: 'Rewritten content',
followUpContext: { id: 'ctx1' },
});
});
it('summarizes conversation', async () => {
TasksAPI.summarize.mockResolvedValue({
data: { message: 'Summary', follow_up_context: { id: 'ctx2' } },
});
const { summarizeConversation } = useCaptain();
const result = await summarizeConversation({});
expect(TasksAPI.summarize).toHaveBeenCalledWith('123', undefined);
expect(result).toEqual({
message: 'Summary',
followUpContext: { id: 'ctx2' },
});
});
it('gets reply suggestion', async () => {
TasksAPI.replySuggestion.mockResolvedValue({
data: { message: 'Reply suggestion', follow_up_context: { id: 'ctx3' } },
});
const { getReplySuggestion } = useCaptain();
const result = await getReplySuggestion({});
expect(TasksAPI.replySuggestion).toHaveBeenCalledWith('123', undefined);
expect(result).toEqual({
message: 'Reply suggestion',
followUpContext: { id: 'ctx3' },
});
});
it('sends follow-up message', async () => {
TasksAPI.followUp.mockResolvedValue({
data: {
message: 'Follow-up response',
follow_up_context: { id: 'ctx4' },
},
});
const { followUp } = useCaptain();
const result = await followUp({
followUpContext: { id: 'ctx3' },
message: 'Make it shorter',
});
expect(TasksAPI.followUp).toHaveBeenCalledWith(
{
followUpContext: { id: 'ctx3' },
message: 'Make it shorter',
conversationId: '123',
},
undefined
);
expect(result).toEqual({
message: 'Follow-up response',
followUpContext: { id: 'ctx4' },
});
});
it('processes event and routes to correct method', async () => {
TasksAPI.summarize.mockResolvedValue({
data: { message: 'Summary' },
});
TasksAPI.replySuggestion.mockResolvedValue({
data: { message: 'Reply' },
});
TasksAPI.rewrite.mockResolvedValue({
data: { message: 'Rewritten' },
});
const { processEvent } = useCaptain();
// Test summarize
await processEvent('summarize', '', {});
expect(TasksAPI.summarize).toHaveBeenCalled();
// Test reply_suggestion
await processEvent('reply_suggestion', '', {});
expect(TasksAPI.replySuggestion).toHaveBeenCalled();
// Test rewrite (improve)
await processEvent('improve', 'content', {});
expect(TasksAPI.rewrite).toHaveBeenCalled();
});
it('returns empty array when no conversation ID for label suggestions', async () => {
useMapGetter.mockImplementation(getter => {
const mockValues = {
'accounts/getUIFlags': { isFetchingLimits: false },
getSelectedChat: { id: null },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
const { getLabelSuggestions } = useCaptain();
const result = await getLabelSuggestions();
expect(result).toEqual([]);
expect(TasksAPI.labelSuggestion).not.toHaveBeenCalled();
});
});
@@ -1,203 +0,0 @@
import { computed, onMounted } from 'vue';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import OpenAPI from 'dashboard/api/integrations/openapi';
/**
* Cleans and normalizes a list of labels.
* @param {string} labels - A comma-separated string of labels.
* @returns {string[]} An array of cleaned and unique labels.
*/
const cleanLabels = labels => {
return labels
.toLowerCase() // Set it to lowercase
.split(',') // split the string into an array
.filter(label => label.trim()) // remove any empty strings
.map(label => label.trim()) // trim the words
.filter((label, index, self) => self.indexOf(label) === index);
};
/**
* A composable function for AI-related operations in the dashboard.
* @returns {Object} An object containing AI-related methods and computed properties.
*/
export function useAI() {
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
/**
* Computed property for UI flags.
* @type {import('vue').ComputedRef<Object>}
*/
const uiFlags = computed(() => getters['integrations/getUIFlags'].value);
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
/**
* Computed property for the AI integration.
* @type {import('vue').ComputedRef<Object|undefined>}
*/
const aiIntegration = computed(
() =>
appIntegrations.value.find(
integration => integration.id === 'openai' && !!integration.hooks.length
)?.hooks[0]
);
/**
* Computed property to check if AI integration is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isAIIntegrationEnabled = computed(() => !!aiIntegration.value);
/**
* Computed property to check if label suggestion feature is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isLabelSuggestionFeatureEnabled = computed(() => {
if (aiIntegration.value) {
const { settings = {} } = aiIntegration.value || {};
return settings.label_suggestion;
}
return false;
});
/**
* Computed property to check if app integrations are being fetched.
* @type {import('vue').ComputedRef<boolean>}
*/
const isFetchingAppIntegrations = computed(() => uiFlags.value.isFetching);
/**
* Computed property for the hook ID.
* @type {import('vue').ComputedRef<string|undefined>}
*/
const hookId = computed(() => aiIntegration.value?.id);
/**
* Computed property for the conversation ID.
* @type {import('vue').ComputedRef<string|undefined>}
*/
const conversationId = computed(() => currentChat.value?.id);
/**
* Computed property for the draft key.
* @type {import('vue').ComputedRef<string>}
*/
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
/**
* Computed property for the draft message.
* @type {import('vue').ComputedRef<string>}
*/
const draftMessage = computed(() =>
getters['draftMessages/get'].value(draftKey.value)
);
/**
* Fetches integrations if they haven't been loaded yet.
* @returns {Promise<void>}
*/
const fetchIntegrationsIfRequired = async () => {
if (!appIntegrations.value.length) {
await store.dispatch('integrations/get');
}
};
/**
* Records analytics for AI-related events.
* @param {string} type - The type of event.
* @param {Object} payload - Additional data for the event.
* @returns {Promise<void>}
*/
const recordAnalytics = async (type, payload) => {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
useTrack(event, {
type,
...payload,
});
}
};
/**
* Fetches label suggestions for the current conversation.
* @returns {Promise<string[]>} An array of suggested labels.
*/
const fetchLabelSuggestions = async () => {
if (!conversationId.value) return [];
try {
const result = await OpenAPI.processEvent({
type: 'label_suggestion',
hookId: hookId.value,
conversationId: conversationId.value,
});
const {
data: { message: labels },
} = result;
return cleanLabels(labels);
} catch {
return [];
}
};
/**
* Processes an AI event, such as rephrasing content.
* @param {string} [type='rephrase'] - The type of AI event to process.
* @returns {Promise<string>} The generated message or an empty string if an error occurs.
*/
const processEvent = async (type = 'rephrase') => {
try {
const result = await OpenAPI.processEvent({
hookId: hookId.value,
type,
content: draftMessage.value,
conversationId: conversationId.value,
});
const {
data: { message: generatedMessage },
} = result;
return generatedMessage;
} catch (error) {
const errorData = error.response.data.error;
const errorMessage =
errorData?.error?.message ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
return '';
}
};
onMounted(() => {
fetchIntegrationsIfRequired();
});
return {
draftMessage,
uiFlags,
appIntegrations,
currentChat,
replyMode,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
isFetchingAppIntegrations,
fetchIntegrationsIfRequired,
recordAnalytics,
fetchLabelSuggestions,
processEvent,
};
}
@@ -1,20 +1,56 @@
import { computed } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import TasksAPI from 'dashboard/api/captain/tasks';
/**
* Cleans and normalizes a list of labels.
* @param {string} labels - A comma-separated string of labels.
* @returns {string[]} An array of cleaned and unique labels.
*/
const cleanLabels = labels => {
return labels
.toLowerCase()
.split(',')
.filter(label => label.trim())
.map(label => label.trim())
.filter((label, index, self) => self.indexOf(label) === index);
};
export function useCaptain() {
const store = useStore();
const { t } = useI18n();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const uiFlags = useMapGetter('accounts/getUIFlags');
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
const conversationId = computed(() => currentChat.value?.id);
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
const draftMessage = useFunctionGetter('draftMessages/get', draftKey);
// === Feature Flags ===
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
});
const captainTasksEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
});
// === Limits (Enterprise) ===
const captainLimits = computed(() => {
return currentAccount.value?.limits?.captain;
});
@@ -23,7 +59,6 @@ export function useCaptain() {
if (captainLimits.value?.documents) {
return useCamelCase(captainLimits.value.documents);
}
return null;
});
@@ -31,7 +66,6 @@ export function useCaptain() {
if (captainLimits.value?.responses) {
return useCamelCase(captainLimits.value.responses);
}
return null;
});
@@ -43,12 +77,198 @@ export function useCaptain() {
}
};
// === Error Handling ===
/**
* Handles API errors and displays appropriate error messages.
* Silently returns for aborted requests.
* @param {Error} error - The error object from the API call.
*/
const handleAPIError = error => {
if (error.name === 'AbortError' || error.name === 'CanceledError') {
return;
}
const errorMessage =
error.response?.data?.error ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
};
// === Analytics ===
/**
* Records analytics for AI-related events.
* @param {string} type - The type of event.
* @param {Object} payload - Additional data for the event.
* @returns {Promise<void>}
*/
const recordAnalytics = async (type, payload) => {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
useTrack(event, {
type,
...payload,
});
}
};
// === Task Methods ===
/**
* Rewrites content with a specific operation.
* @param {string} content - The content to rewrite.
* @param {string} operation - The operation (fix_spelling_grammar, casual, professional, expand, shorten, improve, etc).
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The rewritten content and optional follow-up context.
*/
const rewriteContent = async (content, operation, options = {}) => {
try {
const result = await TasksAPI.rewrite(
{
content: content || draftMessage.value,
operation,
conversationId: conversationId.value,
},
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
}
};
/**
* Summarizes a conversation.
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The summary and optional follow-up context.
*/
const summarizeConversation = async (options = {}) => {
try {
const result = await TasksAPI.summarize(
conversationId.value,
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
}
};
/**
* Gets a reply suggestion for the current conversation.
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The reply suggestion and optional follow-up context.
*/
const getReplySuggestion = async (options = {}) => {
try {
const result = await TasksAPI.replySuggestion(
conversationId.value,
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
}
};
/**
* Gets label suggestions for the current conversation.
* @returns {Promise<string[]>} An array of suggested labels.
*/
const getLabelSuggestions = async () => {
if (!conversationId.value) return [];
try {
const result = await TasksAPI.labelSuggestion(conversationId.value);
const {
data: { message: labels },
} = result;
return cleanLabels(labels);
} catch {
return [];
}
};
/**
* 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) {
handleAPIError(error);
return { message: '', followUpContext };
}
};
/**
* Processes an AI event. Routes to the appropriate method based on type.
* @param {string} [type='improve'] - The type of AI event to process.
* @param {string} [content=''] - The content to process.
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The generated message and optional follow-up context.
*/
const processEvent = async (type = 'improve', content = '', options = {}) => {
if (type === 'summarize') {
return summarizeConversation(options);
}
if (type === 'reply_suggestion') {
return getReplySuggestion(options);
}
// All other types are rewrite operations
return rewriteContent(content, type, options);
};
return {
// Feature flags
captainEnabled,
captainTasksEnabled,
// Limits (Enterprise)
captainLimits,
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
// Conversation context
draftMessage,
currentChat,
// Task methods
rewriteContent,
summarizeConversation,
getReplySuggestion,
getLabelSuggestions,
followUp,
processEvent,
// Analytics
recordAnalytics,
};
}
@@ -0,0 +1,162 @@
import { ref, computed } from 'vue';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useUISettings } from 'dashboard/composables/useUISettings';
/**
* Composable for managing Copilot reply generation state and actions.
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
*
* @returns {Object} Copilot reply state and methods
*/
export function useCopilotReply() {
const { processEvent, followUp } = useCaptain();
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);
const isButtonDisabled = computed(
() => isGenerating.value || !isContentReady.value
);
const editorTransitionKey = computed(() =>
isActive.value ? 'copilot' : 'rich'
);
/**
* Resets all copilot editor state and cancels any ongoing generation.
*/
function reset() {
if (abortController.value) {
abortController.value.abort();
abortController.value = null;
}
showEditor.value = false;
isGenerating.value = false;
isContentReady.value = false;
generatedContent.value = '';
followUpContext.value = null;
}
/**
* Toggles the copilot editor visibility.
*/
function toggleEditor() {
showEditor.value = !showEditor.value;
}
/**
* Marks content as ready (called after transition completes).
*/
function setContentReady() {
isContentReady.value = true;
}
/**
* Executes a copilot action (e.g., improve, fix grammar).
* @param {string} action - The action type
* @param {string} data - The content to process
*/
async function execute(action, data) {
if (action === 'ask_copilot') {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
});
return;
}
// Reset and start new generation
reset();
abortController.value = new AbortController();
isGenerating.value = true;
isContentReady.value = false;
try {
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;
}
} catch {
if (!abortController.value?.signal.aborted) {
isGenerating.value = false;
}
}
}
/**
* 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
* createState function based on the channel's schema.
* @returns {string} The content ready for the editor
*/
function accept() {
const content = generatedContent.value;
showEditor.value = false;
return content;
}
return {
showEditor,
isGenerating,
isContentReady,
generatedContent,
followUpContext,
isActive,
isButtonDisabled,
editorTransitionKey,
reset,
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';
}
+24 -3
View File
@@ -7,6 +7,7 @@ export const FORMATTING = {
marks: ['strong', 'em', 'code', 'link'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -21,6 +22,7 @@ export const FORMATTING = {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -35,12 +37,13 @@ export const FORMATTING = {
'Channel::Api': {
marks: ['strong', 'em'],
nodes: [],
menu: ['strong', 'em', 'undo', 'redo'],
menu: ['copilot', 'strong', 'em', 'undo', 'redo'],
},
'Channel::FacebookPage': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -70,6 +73,7 @@ export const FORMATTING = {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -83,17 +87,18 @@ export const FORMATTING = {
'Channel::Line': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['codeBlock'],
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
menu: ['copilot', 'strong', 'em', 'code', 'strike', 'undo', 'redo'],
},
'Channel::Telegram': {
marks: ['strong', 'em', 'link', 'code'],
nodes: [],
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
menu: ['copilot', 'strong', 'em', 'link', 'code', 'undo', 'redo'],
},
'Channel::Instagram': {
marks: ['strong', 'em', 'code', 'strike'],
nodes: ['bulletList', 'orderedList'],
menu: [
'copilot',
'strong',
'em',
'code',
@@ -115,6 +120,22 @@ export const FORMATTING = {
menu: [],
},
// Special contexts (not actual channels)
'Context::PrivateNote': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
menu: [
'copilot',
'strong',
'em',
'code',
'link',
'strike',
'bulletList',
'orderedList',
'undo',
'redo',
],
},
'Context::Default': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
+1
View File
@@ -40,6 +40,7 @@ export const FEATURE_FLAGS = {
CHANNEL_TIKTOK: 'channel_tiktok',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_V2: 'captain_integration_v2',
CAPTAIN_TASKS: 'captain_tasks',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
@@ -88,6 +88,7 @@ export const OPEN_AI_EVENTS = Object.freeze({
SUMMARIZE: 'OpenAI: Used summarize',
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
REPHRASE: 'OpenAI: Used rephrase',
IMPROVE: 'OpenAI: Used improve',
FIX_SPELLING_AND_GRAMMAR: 'OpenAI: Used fix spelling and grammar',
SHORTEN: 'OpenAI: Used shorten',
EXPAND: 'OpenAI: Used expand',
@@ -519,12 +519,19 @@ export const getContentNode = (
/**
* Get the formatting configuration for a specific channel type.
* Returns the appropriate marks, nodes, and menu items for the editor.
* TODO: We're hiding captain, enable it back when we add selection improvements
*
* @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
* @returns {Object} The formatting configuration with marks, nodes, and menu properties
*/
export function getFormattingForEditor(channelType) {
return FORMATTING[channelType] || FORMATTING['Context::Default'];
export function getFormattingForEditor(channelType, showCaptain = false) {
const formatting = FORMATTING[channelType] || FORMATTING['Context::Default'];
return {
...formatting,
menu: showCaptain
? formatting.menu
: formatting.menu.filter(item => item !== 'copilot'),
};
}
/**
@@ -795,62 +795,6 @@ describe('getContentNode', () => {
});
describe('getFormattingForEditor', () => {
describe('channel-specific formatting', () => {
it('returns full formatting for Email channel', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toEqual(FORMATTING['Channel::Email']);
});
it('returns full formatting for WebWidget channel', () => {
const result = getFormattingForEditor('Channel::WebWidget');
expect(result).toEqual(FORMATTING['Channel::WebWidget']);
});
it('returns limited formatting for WhatsApp channel', () => {
const result = getFormattingForEditor('Channel::Whatsapp');
expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
});
it('returns no formatting for API channel', () => {
const result = getFormattingForEditor('Channel::Api');
expect(result).toEqual(FORMATTING['Channel::Api']);
});
it('returns limited formatting for FacebookPage channel', () => {
const result = getFormattingForEditor('Channel::FacebookPage');
expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
});
it('returns no formatting for TwitterProfile channel', () => {
const result = getFormattingForEditor('Channel::TwitterProfile');
expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
});
it('returns no formatting for SMS channel', () => {
const result = getFormattingForEditor('Channel::Sms');
expect(result).toEqual(FORMATTING['Channel::Sms']);
});
it('returns limited formatting for Telegram channel', () => {
const result = getFormattingForEditor('Channel::Telegram');
expect(result).toEqual(FORMATTING['Channel::Telegram']);
});
it('returns formatting for Instagram channel', () => {
const result = getFormattingForEditor('Channel::Instagram');
expect(result).toEqual(FORMATTING['Channel::Instagram']);
});
});
describe('context-specific formatting', () => {
it('returns default formatting for Context::Default', () => {
const result = getFormattingForEditor('Context::Default');
@@ -186,6 +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... Press enter to send follow-up",
"CLICK_HERE": "Click here to update",
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
},
@@ -205,7 +206,7 @@
"DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"": "",
"COPILOT_THINKING": "Copilot is thinking",
"EMAIL_HEAD": {
"TO": "TO",
"ADD_BCC": "Add bcc",
@@ -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"
}
}
@@ -145,7 +145,29 @@
"EXPAND": "Expand",
"MAKE_FRIENDLY": "Change message tone to friendly",
"MAKE_FORMAL": "Use formal tone",
"SIMPLIFY": "Simplify"
"SIMPLIFY": "Simplify",
"CONFIDENT": "Use confident tone",
"PROFESSIONAL": "Use professional tone",
"CASUAL": "Use casual tone",
"STRAIGHTFORWARD": "Use straightforward tone"
},
"REPLY_OPTIONS": {
"IMPROVE_REPLY": "Improve reply",
"IMPROVE_REPLY_SELECTION": "Improve the selection",
"CHANGE_TONE": {
"TITLE": "Change tone",
"OPTIONS": {
"PROFESSIONAL": "Professional",
"CASUAL": "Casual",
"STRAIGHTFORWARD": "Straightforward",
"CONFIDENT": "Confident",
"FRIENDLY": "Friendly"
}
},
"GRAMMAR": "Fix grammar & spelling",
"SUGGESTION": "Suggest a reply",
"SUMMARIZE": "Summarize the conversation",
"ASK_COPILOT": "Ask Copilot"
},
"ASSISTANCE_MODAL": {
"DRAFT_TITLE": "Draft content",
@@ -402,22 +402,48 @@
},
"CSAT_REPORTS": {
"HEADER": "CSAT Reports",
"NO_RECORDS": "There are no CSAT survey responses available.",
"NO_RECORDS": "No responses yet",
"NO_RECORDS_DESCRIPTION": "CSAT survey responses will appear here once customers start providing feedback.",
"DOWNLOAD": "Download CSAT Reports",
"DOWNLOAD_FAILED": "Failed to download CSAT Reports",
"FILTERS": {
"ADD_FILTER": "Add filter",
"CLEAR_ALL": "Clear all",
"NO_FILTER": "No filters available",
"INPUT_PLACEHOLDER": {
"AGENTS": "Search agents",
"INBOXES": "Search inboxes",
"TEAMS": "Search teams",
"RATINGS": "Search ratings"
},
"AGENTS": {
"PLACEHOLDER": "Choose Agents"
"LABEL": "Agent"
},
"INBOXES": {
"LABEL": "Inbox"
},
"TEAMS": {
"LABEL": "Team"
},
"RATINGS": {
"LABEL": "Rating"
}
},
"TABLE": {
"HEADER": {
"CONTACT_NAME": "Contact",
"AGENT_NAME": "Assigned agent",
"AGENT_NAME": "Agent",
"RATING": "Rating",
"FEEDBACK_TEXT": "Feedback comment"
}
"FEEDBACK_TEXT": "Feedback comment",
"CONVERSATION": "Conversation",
"CUSTOMER": "Customer",
"RESPONSE": "Response",
"HANDLED_BY": "Handled by"
},
"UNKNOWN_CUSTOMER": "Unknown customer"
},
"NO_AGENT": "No assigned agent",
"NO_FEEDBACK": "No feedback provided",
"METRIC": {
"TOTAL_RESPONSES": {
"LABEL": "Total responses",
@@ -430,6 +456,25 @@
"RESPONSE_RATE": {
"LABEL": "Response rate",
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100"
},
"RATING_DISTRIBUTION": "Rating distribution"
},
"REVIEW_NOTES": {
"TITLE": "Review notes",
"PLACEHOLDER": "Add review notes about this rating...",
"SAVE": "Save",
"CANCEL": "Cancel",
"SAVING": "Saving...",
"SAVED": "Notes saved successfully",
"SAVE_ERROR": "Failed to save notes",
"UPDATED_BY": "Updated by {name} {time}",
"UPDATED_BY_LABEL": "Updated by",
"PAYWALL": {
"TITLE": "Upgrade to add review notes",
"AVAILABLE_ON": "The review notes feature is only available in the Business and Enterprise plans.",
"UPGRADE_PROMPT": "Add internal context to every CSAT response with review notes. Capture what really happened, spot patterns faster, and make better decisions from your feedback.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
}
}
},
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { computed, watch, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -49,10 +49,14 @@ const handleCreateClose = () => {
selectedInbox.value = null;
};
onMounted(() =>
store.dispatch('captainInboxes/get', {
assistantId: assistantId.value,
})
watch(
assistantId,
newId => {
store.dispatch('captainInboxes/get', {
assistantId: newId,
});
},
{ immediate: true }
);
</script>
@@ -3,7 +3,7 @@ import { mapGetters } from 'vuex';
import { useAlert, useTrack } from 'dashboard/composables';
import CsatMetrics from './components/CsatMetrics.vue';
import CsatTable from './components/CsatTable.vue';
import ReportFilterSelector from './components/FilterSelector.vue';
import CsatFilters from './components/Csat/CsatFilters.vue';
import { generateFileName } from '../../../../helper/downloadHelper';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { FEATURE_FLAGS } from '../../../../featureFlags';
@@ -15,7 +15,7 @@ export default {
components: {
CsatMetrics,
CsatTable,
ReportFilterSelector,
CsatFilters,
ReportHeader,
V4Button,
},
@@ -90,7 +90,7 @@ export default {
selectedTeam,
selectedRating,
}) {
// do not track filter change on inital load
// do not track filter change on initial load
if (this.from !== 0 && this.to !== 0) {
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
filterType: 'date',
@@ -121,16 +121,11 @@ export default {
/>
</ReportHeader>
<div class="flex flex-col gap-4">
<ReportFilterSelector
show-agents-filter
show-inbox-filter
show-rating-filter
<div class="flex flex-col gap-6">
<CsatFilters
:show-team-filter="isTeamsEnabled"
:show-business-hours-switch="false"
@filter-change="onFilterChange"
/>
<CsatMetrics :filters="requestPayload" />
<CsatTable :page-index="pageIndex" @page-change="onPageNumberChange" />
</div>
@@ -1,26 +0,0 @@
<script setup>
import { computed } from 'vue';
const { row } = defineProps({
row: {
type: Object,
required: true,
},
});
const routerParams = computed(() => ({
name: 'inbox_conversation',
params: { conversation_id: row.original.conversationId },
}));
</script>
<template>
<div class="text-right">
<router-link :to="routerParams" class="hover:underline">
{{ `#${row.original.conversationId}` }}
</router-link>
<div v-tooltip="row.original.createdAt" class="text-n-slate-11 text-sm">
{{ row.original.createdAgo }}
</div>
</div>
</template>
@@ -0,0 +1,36 @@
import { CSAT_RATINGS } from 'shared/constants/messages';
export const buildFilterList = (items, type) =>
items.map(item => ({
id: item.id,
name: type === 'ratings' ? item.emoji : item.name,
type,
}));
export const buildRatingsList = t =>
CSAT_RATINGS.map(rating => ({
id: rating.value,
name: `${t(rating.translationKey)}`,
type: 'ratings',
}));
export const getActiveFilter = (filters, type, key) =>
filters.find(item => item.id.toString() === key.toString());
export const getFilterType = (input, direction) => {
const filterMap = {
keyToType: {
user_ids: 'agents',
inbox_id: 'inboxes',
team_id: 'teams',
rating: 'ratings',
},
typeToKey: {
agents: 'user_ids',
inboxes: 'inbox_id',
teams: 'team_id',
ratings: 'rating',
},
};
return filterMap[direction][input];
};
@@ -0,0 +1,267 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
import {
buildFilterList,
buildRatingsList,
getActiveFilter,
getFilterType,
} from './CsatFilterHelpers';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import ActiveFilterChip from '../Filters/v3/ActiveFilterChip.vue';
import AddFilterChip from '../Filters/v3/AddFilterChip.vue';
import WootDatePicker from 'dashboard/components/ui/DatePicker/DatePicker.vue';
const props = defineProps({
showTeamFilter: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['filterChange']);
const { t } = useI18n();
const store = useStore();
const showDropdownMenu = ref(false);
const showSubDropdownMenu = ref(false);
const activeFilterType = ref('');
const customDateRange = ref([new Date(), new Date()]);
const appliedFilters = ref({
user_ids: null,
inbox_id: null,
team_id: null,
rating: null,
});
const agents = computed(() => store.getters['agents/getAgents']);
const inboxes = computed(() => store.getters['inboxes/getInboxes']);
const teams = computed(() => store.getters['teams/getTeams']);
const ratings = computed(() => buildRatingsList(t));
const from = computed(() => getUnixStartOfDay(customDateRange.value[0]));
const to = computed(() => getUnixEndOfDay(customDateRange.value[1]));
const getFilterSource = type => {
const sources = {
agents: agents.value,
inboxes: inboxes.value,
teams: teams.value,
ratings: ratings.value,
};
return sources[type] || [];
};
const getFilterOptions = type => {
if (type === 'ratings') {
return ratings.value;
}
return buildFilterList(getFilterSource(type), type);
};
const filterListMenuItems = computed(() => {
const filterTypes = [
{
id: '1',
name: t('CSAT_REPORTS.FILTERS.AGENTS.LABEL'),
type: 'agents',
},
{
id: '2',
name: t('CSAT_REPORTS.FILTERS.INBOXES.LABEL'),
type: 'inboxes',
},
{
id: '3',
name: t('CSAT_REPORTS.FILTERS.RATINGS.LABEL'),
type: 'ratings',
},
];
if (props.showTeamFilter) {
filterTypes.splice(2, 0, {
id: '4',
name: t('CSAT_REPORTS.FILTERS.TEAMS.LABEL'),
type: 'teams',
});
}
const activeFilterKeys = Object.keys(appliedFilters.value).filter(
key => appliedFilters.value[key]
);
const activeFilterTypes = activeFilterKeys.map(key =>
getFilterType(key, 'keyToType')
);
return filterTypes
.filter(({ type }) => !activeFilterTypes.includes(type))
.map(({ id, name, type }) => ({
id,
name,
type,
options: getFilterOptions(type),
}));
});
const activeFilters = computed(() => {
const activeKeys = Object.keys(appliedFilters.value).filter(
key => appliedFilters.value[key]
);
return activeKeys.map(key => {
const filterType = getFilterType(key, 'keyToType');
const items = getFilterSource(filterType);
const item = getActiveFilter(items, filterType, appliedFilters.value[key]);
return {
id: item?.id,
name: item?.name || '',
type: filterType,
options: getFilterOptions(filterType),
};
});
});
const hasActiveFilters = computed(() =>
Object.values(appliedFilters.value).some(value => value !== null)
);
const isAllFilterSelected = computed(() => !filterListMenuItems.value.length);
const emitChange = () => {
emit('filterChange', {
from: from.value,
to: to.value,
selectedAgents: appliedFilters.value.user_ids
? [{ id: appliedFilters.value.user_ids }]
: [],
selectedInbox: appliedFilters.value.inbox_id
? { id: appliedFilters.value.inbox_id }
: null,
selectedTeam: appliedFilters.value.team_id
? { id: appliedFilters.value.team_id }
: null,
selectedRating: appliedFilters.value.rating
? { value: appliedFilters.value.rating }
: null,
});
};
const closeDropdown = () => {
showDropdownMenu.value = false;
};
const closeActiveFilterDropdown = () => {
activeFilterType.value = '';
showSubDropdownMenu.value = false;
};
const resetDropdown = () => {
closeDropdown();
closeActiveFilterDropdown();
};
const addFilter = item => {
const { type, id } = item;
const filterKey = getFilterType(type, 'typeToKey');
appliedFilters.value[filterKey] = id;
emitChange();
resetDropdown();
};
const removeFilter = type => {
const filterKey = getFilterType(type, 'typeToKey');
appliedFilters.value[filterKey] = null;
emitChange();
};
const clearAllFilters = () => {
appliedFilters.value = {
user_ids: null,
inbox_id: null,
team_id: null,
rating: null,
};
emitChange();
resetDropdown();
};
const showDropdown = () => {
showSubDropdownMenu.value = false;
showDropdownMenu.value = !showDropdownMenu.value;
};
const openActiveFilterDropdown = filterType => {
closeDropdown();
activeFilterType.value = filterType;
showSubDropdownMenu.value = !showSubDropdownMenu.value;
};
const onDateRangeChange = value => {
customDateRange.value = value;
emitChange();
};
onMounted(() => {
emitChange();
});
</script>
<template>
<div class="flex flex-col flex-wrap w-full gap-3 md:flex-row">
<WootDatePicker @date-range-changed="onDateRangeChange" />
<div
class="flex flex-col flex-wrap items-start gap-2 md:items-center md:flex-nowrap md:flex-row"
>
<div v-if="hasActiveFilters" class="flex flex-wrap gap-2 md:flex-nowrap">
<ActiveFilterChip
v-for="filter in activeFilters"
v-bind="filter"
:key="filter.type"
:placeholder="
$t(
`CSAT_REPORTS.FILTERS.INPUT_PLACEHOLDER.${filter.type.toUpperCase()}`
)
"
:active-filter-type="activeFilterType"
:show-menu="showSubDropdownMenu"
enable-search
@toggle-dropdown="openActiveFilterDropdown"
@close-dropdown="closeActiveFilterDropdown"
@add-filter="addFilter"
@remove-filter="removeFilter"
/>
</div>
<div
v-if="hasActiveFilters && !isAllFilterSelected"
class="w-full h-px border md:w-px md:h-5 border-n-weak"
/>
<div class="flex items-center gap-2">
<AddFilterChip
v-if="!isAllFilterSelected"
placeholder-i18n-key="CSAT_REPORTS.FILTERS.INPUT_PLACEHOLDER"
:name="$t('CSAT_REPORTS.FILTERS.ADD_FILTER')"
:menu-option="filterListMenuItems"
:show-menu="showDropdownMenu"
:empty-state-message="$t('CSAT_REPORTS.FILTERS.NO_FILTER')"
@toggle-dropdown="showDropdown"
@close-dropdown="closeDropdown"
@add-filter="addFilter"
/>
<div v-if="hasActiveFilters" class="w-px h-5 border border-n-weak" />
<FilterButton
v-if="hasActiveFilters"
:button-text="$t('CSAT_REPORTS.FILTERS.CLEAR_ALL')"
@click="clearAllFilters"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,52 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
defineProps({
contact: {
type: Object,
default: null,
},
conversationId: {
type: Number,
required: true,
},
createdAgo: {
type: String,
default: '',
},
createdAt: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex items-center gap-2">
<Avatar
:src="contact?.thumbnail || ''"
:name="contact?.name || ''"
:size="32"
rounded-full
/>
<div class="flex flex-col gap-0.5">
<span class="text-sm text-n-slate-12 font-medium capitalize">
{{ contact?.name || '—' }}
</span>
<div
class="flex items-center gap-1 text-xs text-n-slate-10 whitespace-nowrap"
>
<a
:href="`/app/accounts/${$route.params.accountId}/conversations/${conversationId}`"
class="flex items-center text-xs gap-0.5 hover:text-n-brand hover:underline"
target="_blank"
rel="noopener noreferrer nofollow"
>
<span>#{{ conversationId }}</span>
</a>
<span>·</span>
<span :title="createdAt">{{ createdAgo }}</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,32 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
icon: {
type: String,
default: 'i-lucide-message-square-off',
},
});
</script>
<template>
<div class="flex flex-col items-center justify-center py-16 px-6 text-center">
<div
class="size-16 rounded-full bg-n-alpha-2 flex items-center justify-center mb-4"
>
<i :class="icon" class="size-8 text-n-slate-10" />
</div>
<h3 class="text-base font-medium text-n-slate-12 mb-1">
{{ title }}
</h3>
<p v-if="description" class="text-sm text-n-slate-10 max-w-sm">
{{ description }}
</p>
</div>
</template>
@@ -0,0 +1,162 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import CsatReviewNotesPaywall from './CsatReviewNotesPaywall.vue';
import { dynamicTime } from 'shared/helpers/timeHelper';
const props = defineProps({
response: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const store = useStore();
const { isCloudFeatureEnabled, isOnChatwootCloud } = useAccount();
const { formatMessage } = useMessageFormatter();
const isFeatureEnabled = computed(() =>
isCloudFeatureEnabled('csat_review_notes')
);
const showPaywall = computed(
() => !isFeatureEnabled.value && isOnChatwootCloud.value
);
const reviewNotes = ref(props.response.csat_review_notes || '');
const isEditing = ref(!props.response.csat_review_notes);
const isSaving = ref(false);
const hasExistingReviewNotes = computed(
() => !!props.response.csat_review_notes
);
const hasChanges = computed(
() => reviewNotes.value !== (props.response.csat_review_notes || '')
);
const startEditing = () => {
isEditing.value = true;
};
const cancelEditing = () => {
reviewNotes.value = props.response.csat_review_notes || '';
if (hasExistingReviewNotes.value) {
isEditing.value = false;
}
};
const saveReviewNotes = async () => {
isSaving.value = true;
try {
await store.dispatch('csat/update', {
id: props.response.id,
reviewNotes: reviewNotes.value,
});
useAlert(t('CSAT_REPORTS.REVIEW_NOTES.SAVED'));
isEditing.value = false;
} catch {
useAlert(t('CSAT_REPORTS.REVIEW_NOTES.SAVE_ERROR'));
} finally {
isSaving.value = false;
}
};
</script>
<template>
<div class="py-4 px-5 border-t border-n-container bg-n-background">
<CsatReviewNotesPaywall v-if="showPaywall" />
<div v-else-if="isFeatureEnabled" class="flex flex-col gap-3">
<div class="flex items-start gap-4">
<div
class="flex items-center gap-1.5 text-n-slate-11 shrink-0 w-36 pt-3"
>
<i class="i-lucide-notebook-pen size-4" />
<span class="text-sm font-medium">
{{ $t('CSAT_REPORTS.REVIEW_NOTES.TITLE') }}
</span>
</div>
<div class="flex-1 max-w-2xl">
<div
v-if="hasExistingReviewNotes && !isEditing"
class="group flex items-start gap-2 py-2 px-3 rounded-lg hover:bg-n-slate-2 dark:hover:bg-n-solid-3 cursor-pointer transition-colors"
@click.stop="startEditing"
>
<p
v-dompurify-html="formatMessage(response.csat_review_notes || '')"
class="flex-1 text-sm text-n-slate-12 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0"
/>
<i
class="i-lucide-pencil size-4 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5"
/>
</div>
<div
v-else
class="flex flex-col gap-3 [&_.ProseMirror]:min-h-32 [&_.ProseMirror]:max-h-64 [&_.ProseMirror-menubar]:!mt-0"
@click.stop
>
<Editor
v-model="reviewNotes"
:placeholder="$t('CSAT_REPORTS.REVIEW_NOTES.PLACEHOLDER')"
:show-character-count="false"
:enable-canned-responses="false"
focus-on-mount
>
<template #actions>
<div class="flex items-center gap-2 py-2">
<Button
v-if="hasExistingReviewNotes"
variant="ghost"
size="xs"
:label="$t('CSAT_REPORTS.REVIEW_NOTES.CANCEL')"
@click.stop="cancelEditing"
/>
<Button
:label="$t('CSAT_REPORTS.REVIEW_NOTES.SAVE')"
:disabled="!hasChanges || isSaving"
:loading="isSaving"
size="xs"
@click.stop="saveReviewNotes"
/>
</div>
</template>
</Editor>
</div>
</div>
</div>
<div
v-if="
hasExistingReviewNotes &&
!isEditing &&
response.review_notes_updated_by
"
class="flex items-center gap-4"
>
<div class="flex items-center gap-1.5 text-n-slate-11 shrink-0 w-36">
<i class="i-lucide-user-pen size-4" />
<span class="text-sm font-medium">
{{ $t('CSAT_REPORTS.REVIEW_NOTES.UPDATED_BY_LABEL') }}
</span>
</div>
<div class="flex items-center gap-1 flex-1 max-w-2xl px-3">
<span class="text-sm text-n-slate-12">
{{ response.review_notes_updated_by.name }}
</span>
<span class="text-n-slate-10">·</span>
<span class="text-sm text-n-slate-10">
{{ dynamicTime(response.review_notes_updated_at) }}
</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,53 @@
<script setup>
import CsatMetricCard from './CsatMetricCard.vue';
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/Reports/CsatMetricCard"
:layout="{ type: 'grid', width: '400px' }"
>
<Variant title="Default">
<div class="p-4 bg-n-background">
<CsatMetricCard
label="Total Responses"
tooltip="Total number of responses received"
value="1,234"
/>
</div>
</Variant>
<Variant title="Percentage Value">
<div class="p-4 bg-n-background">
<CsatMetricCard
label="Satisfaction Score"
tooltip="Percentage of positive responses"
value="85%"
/>
</div>
</Variant>
<Variant title="Loading State">
<div class="p-4 bg-n-background">
<CsatMetricCard
label="Response Rate"
tooltip="Percentage of conversations with responses"
value="0"
is-loading
/>
</div>
</Variant>
<Variant title="Zero Value">
<div class="p-4 bg-n-background">
<CsatMetricCard
label="Total Responses"
tooltip="Total number of responses received"
value="0"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,41 @@
<script setup>
defineProps({
label: {
type: String,
required: true,
},
tooltip: {
type: String,
required: true,
},
value: {
type: [String, Number],
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
});
</script>
<template>
<div class="flex flex-col gap-2 items-start justify-center min-w-[10rem]">
<span
class="inline-flex items-center gap-1 text-sm font-medium text-n-slate-11"
>
{{ label }}
<span
v-tooltip.right="tooltip"
class="i-lucide-info flex flex-shrink-0 text-n-slate-10 size-3.5"
/>
</span>
<div
v-if="isLoading"
class="w-16 h-8 rounded-md bg-n-slate-3 animate-pulse"
/>
<span v-else class="text-2xl font-medium text-n-slate-12">
{{ value }}
</span>
</div>
</template>
@@ -1,135 +1,63 @@
<script>
import { mapGetters } from 'vuex';
import CsatMetricCard from './ReportMetricCard.vue';
import { CSAT_RATINGS } from 'shared/constants/messages';
import BarChart from 'shared/components/charts/BarChart.vue';
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import CsatMetricCard from './CsatMetricCard.vue';
import CsatRatingDistribution from './CsatRatingDistribution.vue';
export default {
components: { BarChart, CsatMetricCard },
props: {
filters: {
type: Object,
required: true,
},
},
data() {
return {
csatRatings: CSAT_RATINGS,
};
},
computed: {
...mapGetters({
metrics: 'csat/getMetrics',
ratingPercentage: 'csat/getRatingPercentage',
satisfactionScore: 'csat/getSatisfactionScore',
responseRate: 'csat/getResponseRate',
}),
ratingFilterEnabled() {
return Boolean(this.filters.rating);
},
chartData() {
const sortedRatings = [...CSAT_RATINGS].sort((a, b) => b.value - a.value);
return {
labels: ['Rating'],
datasets: sortedRatings.map(rating => ({
label: rating.emoji,
data: [this.ratingPercentage[rating.value]],
backgroundColor: rating.color,
})),
};
},
responseCount() {
return this.metrics.totalResponseCount
? this.metrics.totalResponseCount.toLocaleString()
: '--';
},
chartOptions() {
return {
indexAxis: 'y',
responsive: true,
plugins: {
legend: {
display: false,
},
title: {
display: false,
},
tooltip: {
enabled: false,
},
},
scales: {
x: {
display: false,
stacked: true,
},
y: {
display: false,
stacked: true,
},
},
};
},
},
methods: {
formatToPercent(value) {
return value ? `${value}%` : '--';
},
ratingToEmoji(value) {
return CSAT_RATINGS.find(rating => rating.value === Number(value)).emoji;
},
},
};
const metrics = useMapGetter('csat/getMetrics');
const ratingPercentage = useMapGetter('csat/getRatingPercentage');
const ratingCount = useMapGetter('csat/getRatingCount');
const satisfactionScore = useMapGetter('csat/getSatisfactionScore');
const responseRate = useMapGetter('csat/getResponseRate');
const uiFlags = useMapGetter('csat/getUIFlags');
const isLoading = computed(() => uiFlags.value.isFetchingMetrics);
const responseCount = computed(() =>
metrics.value.totalResponseCount
? metrics.value.totalResponseCount.toLocaleString()
: '0'
);
const formatPercent = value => (value ? `${value}%` : '0%');
</script>
<!-- eslint-disable vue/no-unused-refs -->
<!-- Added ref for writing specs -->
<template>
<div
class="flex-col lg:flex-row flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-8 gap-4"
>
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
:value="responseCount"
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
/>
<CsatMetricCard
:disabled="ratingFilterEnabled"
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
:value="ratingFilterEnabled ? '--' : formatToPercent(satisfactionScore)"
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
/>
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
:value="formatToPercent(responseRate)"
class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]"
/>
<div class="flex flex-col gap-4">
<div
v-if="metrics.totalResponseCount && !ratingFilterEnabled"
ref="csatBarChart"
class="w-full md:w-1/2 md:max-w-[50%] flex-1 rtl:[direction:initial]"
class="flex sm:flex-row flex-col w-full gap-4 sm:gap-14 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
>
<h3
class="flex items-center m-0 text-xs font-medium md:text-sm text-n-slate-12"
>
<div class="flex flex-row-reverse justify-end">
<div
v-for="(rating, key, index) in ratingPercentage"
:key="rating + key + index"
class="ltr:pr-4 rtl:pl-4"
>
<span class="my-0 mx-0.5">{{ ratingToEmoji(key) }}</span>
<span>{{ formatToPercent(rating) }}</span>
</div>
</div>
</h3>
<div class="mt-2 h-6">
<BarChart :collection="chartData" :chart-options="chartOptions" />
</div>
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
:tooltip="$t('CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
:value="responseCount"
:is-loading="isLoading"
/>
<div class="w-full sm:w-px bg-n-strong" />
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
:tooltip="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
:value="formatPercent(satisfactionScore)"
:is-loading="isLoading"
/>
<div class="w-full sm:w-px bg-n-strong" />
<CsatMetricCard
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
:tooltip="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
:value="formatPercent(responseRate)"
:is-loading="isLoading"
/>
</div>
<CsatRatingDistribution
:rating-percentage="ratingPercentage"
:rating-count="ratingCount"
:total-response-count="metrics.totalResponseCount"
:is-loading="isLoading"
/>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup>
import CsatRatingDistribution from './CsatRatingDistribution.vue';
const sampleRatingPercentage = {
1: 5,
2: 10,
3: 15,
4: 25,
5: 45,
};
const sampleRatingCount = {
1: 50,
2: 100,
3: 150,
4: 250,
5: 450,
};
const emptyRatingPercentage = {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
};
const emptyRatingCount = {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
};
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Components/Reports/CsatRatingDistribution"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="With Data">
<div class="p-4 bg-n-background">
<CsatRatingDistribution
:rating-percentage="sampleRatingPercentage"
:rating-count="sampleRatingCount"
:total-response-count="1000"
/>
</div>
</Variant>
<Variant title="Empty State">
<div class="p-4 bg-n-background">
<CsatRatingDistribution
:rating-percentage="emptyRatingPercentage"
:rating-count="emptyRatingCount"
:total-response-count="0"
/>
</div>
</Variant>
<Variant title="Loading State">
<div class="p-4 bg-n-background">
<CsatRatingDistribution
:rating-percentage="emptyRatingPercentage"
:rating-count="emptyRatingCount"
:total-response-count="0"
is-loading
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,101 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { CSAT_RATINGS } from 'shared/constants/messages';
const props = defineProps({
ratingPercentage: {
type: Object,
default: () => ({}),
},
ratingCount: {
type: Object,
default: () => ({}),
},
totalResponseCount: {
type: Number,
default: 0,
},
isLoading: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const sortedRatings = computed(() =>
[...CSAT_RATINGS].sort((a, b) => b.value - a.value)
);
const formatPercent = value => (value ? `${value}%` : '0%');
const getRatingLabel = value => {
const rating = CSAT_RATINGS.find(r => r.value === value);
return rating ? t(rating.translationKey) : '';
};
const getRatingCount = value => {
return props.ratingCount[value] || 0;
};
</script>
<template>
<div
class="shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
>
<span class="text-sm font-medium text-n-slate-11">
{{ $t('CSAT_REPORTS.METRIC.RATING_DISTRIBUTION') }}
</span>
<div v-if="isLoading" class="mt-4">
<div class="h-6 w-full rounded-full bg-n-slate-3 animate-pulse" />
<div class="flex gap-6 mt-4">
<div
v-for="n in 5"
:key="n"
class="h-4 w-20 rounded bg-n-slate-3 animate-pulse"
/>
</div>
</div>
<div v-else class="mt-4">
<div
v-if="totalResponseCount"
class="flex h-6 w-full rounded-full overflow-hidden bg-n-alpha-2"
>
<div
v-for="rating in sortedRatings"
:key="rating.value"
v-tooltip="
`${getRatingLabel(rating.value)}: ${formatPercent(ratingPercentage[rating.value])} (${getRatingCount(rating.value)})`
"
:style="{
width: `${ratingPercentage[rating.value]}%`,
backgroundColor: rating.color,
}"
class="h-full transition-all duration-300 first:rounded-s-full last:rounded-e-full cursor-default"
/>
</div>
<div v-else class="h-6 w-full rounded-full bg-n-alpha-2" />
<div class="flex flex-wrap gap-x-6 gap-y-2 mt-4">
<div
v-for="rating in sortedRatings"
:key="rating.value"
class="flex items-center gap-2"
>
<span class="text-sm text-n-slate-11">
{{ getRatingLabel(rating.value) }}
</span>
<span class="text-sm font-medium text-n-slate-12">
{{ formatPercent(ratingPercentage[rating.value]) }}
</span>
<span class="text-xs text-n-slate-10">
({{ getRatingCount(rating.value) }})
</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup>
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const router = useRouter();
const currentAccountId = useMapGetter('getCurrentAccountId');
const goToBillingSettings = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: currentAccountId.value },
});
};
</script>
<template>
<div class="py-4 px-5 bg-n-background">
<div class="flex justify-center">
<BasePaywallModal
feature-prefix="CSAT_REPORTS.REVIEW_NOTES"
i18n-key="PAYWALL"
is-on-chatwoot-cloud
@upgrade="goToBillingSettings"
/>
</div>
</div>
</template>
@@ -1,19 +1,19 @@
<script setup>
import { defineEmits, computed, h } from 'vue';
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useI18n } from 'vue-i18n';
// [TODO] Instead of converting the values to their reprentation when building the tableData
// We should do the change in the cell
import { messageStamp, dynamicTime } from 'shared/helpers/timeHelper';
// components
import Table from 'dashboard/components/table/Table.vue';
import Pagination from 'dashboard/components/table/Pagination.vue';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import ConversationCell from './ConversationCell.vue';
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
import CsatContactCell from './CsatContactCell.vue';
import CsatExpandedRow from './CsatExpandedRow.vue';
import CsatEmptyState from './CsatEmptyState.vue';
import CsatTableLoader from './CsatTableLoader.vue';
// constants
import { CSAT_RATINGS } from 'shared/constants/messages';
import {
@@ -31,97 +31,82 @@ const { pageIndex } = defineProps({
const emit = defineEmits(['pageChange']);
const { t } = useI18n();
// const isRTL = useMapGetter('accounts/isRTL');
const { isCloudFeatureEnabled, isOnChatwootCloud } = useAccount();
const csatResponses = useMapGetter('csat/getCSATResponses');
const isFeatureEnabled = computed(() =>
isCloudFeatureEnabled('csat_review_notes')
);
const showExpandableRows = computed(
() => isFeatureEnabled.value || isOnChatwootCloud.value
);
const metrics = useMapGetter('csat/getMetrics');
const uiFlags = useMapGetter('csat/getUIFlags');
const isLoading = computed(() => uiFlags.value.isFetching);
const expandedRows = ref({});
const toggleRow = id => {
expandedRows.value = {
...expandedRows.value,
[id]: !expandedRows.value[id],
};
};
const isRowExpanded = id => !!expandedRows.value[id];
const tableData = computed(() => {
return csatResponses.value.map(response => ({
id: response.id,
contact: response.contact,
assignedAgent: response.assigned_agent,
rating: response.rating,
feedbackText: response.feedback_message || '---',
feedbackText: response.feedback_message || '',
conversationId: response.conversation_id,
csatReviewNotes: response.csat_review_notes,
createdAgo: dynamicTime(response.created_at),
createdAt: messageStamp(response.created_at, 'LLL d yyyy, h:mm a'),
_original: response,
}));
});
const defaultSpanRender = cellProps => {
const value = cellProps.getValue() || '---';
return h(
'span',
{
class: 'line-clamp-5 break-words max-w-full text-n-slate-12',
title: value,
},
value
);
const getRatingData = rating => {
return CSAT_RATINGS.find(r => r.value === rating) || {};
};
const columnHelper = createColumnHelper();
const columns = computed(() => [
columnHelper.accessor('contact', {
header: t('CSAT_REPORTS.TABLE.HEADER.CONTACT_NAME'),
width: 200,
cell: cellProps => {
const { contact } = cellProps.row.original;
if (contact) {
return h(UserAvatarWithName, {
user: contact,
class: 'max-w-[200px] overflow-hidden',
});
}
return '--';
},
}),
columnHelper.accessor('assignedAgent', {
header: t('CSAT_REPORTS.TABLE.HEADER.AGENT_NAME'),
width: 200,
cell: cellProps => {
const { assignedAgent } = cellProps.row.original;
if (assignedAgent) {
return h(UserAvatarWithName, {
user: assignedAgent,
class: 'max-w-[200px] overflow-hidden',
});
}
return '--';
},
}),
columnHelper.accessor('rating', {
header: t('CSAT_REPORTS.TABLE.HEADER.RATING'),
align: 'center',
width: 80,
cell: cellProps => {
const { rating: giveRating } = cellProps.row.original;
const [ratingObject = {}] = CSAT_RATINGS.filter(
rating => rating.value === giveRating
);
const columns = computed(() => {
const baseColumns = [
columnHelper.accessor('contact', {
header: t('CSAT_REPORTS.TABLE.HEADER.CONTACT_NAME'),
}),
columnHelper.accessor('rating', {
header: t('CSAT_REPORTS.TABLE.HEADER.RATING'),
size: 120,
}),
columnHelper.accessor('feedbackText', {
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
size: 500,
}),
columnHelper.accessor('assignedAgent', {
header: t('CSAT_REPORTS.TABLE.HEADER.HANDLED_BY'),
size: 160,
}),
];
return h(
'span',
{
class: ratingObject.emoji
? 'emoji-response text-lg'
: 'text-n-slate-10',
},
ratingObject.emoji || '---'
);
},
}),
columnHelper.accessor('feedbackText', {
header: t('CSAT_REPORTS.TABLE.HEADER.FEEDBACK_TEXT'),
width: 400,
cell: defaultSpanRender,
}),
columnHelper.accessor('conversationId', {
header: '',
width: 100,
cell: cellProps => h(ConversationCell, cellProps),
}),
]);
if (showExpandableRows.value) {
baseColumns.push(
columnHelper.accessor('actions', {
header: '',
size: 50,
})
);
}
return baseColumns;
});
const paginationParams = computed(() => {
return {
@@ -149,25 +134,124 @@ const table = useVueTable({
},
},
onPaginationChange: updater => {
const newPagintaion = updater(paginationParams.value);
emit('pageChange', newPagintaion.pageIndex);
const newPagination = updater(paginationParams.value);
emit('pageChange', newPagination.pageIndex);
},
});
</script>
<template>
<div
class="shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
class="shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 overflow-hidden"
>
<Table :table="table" class="max-h-[calc(100vh-21.875rem)]" />
<div
v-show="!tableData.length"
class="h-48 flex items-center justify-center text-n-slate-12 text-sm"
>
{{ $t('CSAT_REPORTS.NO_RECORDS') }}
<CsatTableLoader v-if="isLoading" />
<div v-else-if="tableData.length" class="overflow-x-auto">
<table class="w-full">
<thead class="bg-n-solid-2 border-b border-n-container">
<tr>
<th
v-for="header in table.getFlatHeaders()"
:key="header.id"
:style="{
width: header.getSize() ? `${header.getSize()}px` : 'auto',
}"
class="text-left py-3 px-5 font-medium text-sm text-n-slate-12"
>
{{ header.column.columnDef.header }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-n-container">
<template v-for="row in tableData" :key="row.id">
<tr
class="group hover:bg-n-slate-2 dark:hover:bg-n-solid-3 transition-colors"
:class="{
'bg-n-slate-2 dark:bg-n-solid-3': isRowExpanded(row.id),
'cursor-pointer': showExpandableRows,
}"
@click="showExpandableRows && toggleRow(row.id)"
>
<td class="py-4 px-5">
<CsatContactCell
:contact="row.contact"
:conversation-id="row.conversationId"
:created-ago="row.createdAgo"
:created-at="row.createdAt"
/>
</td>
<td class="py-4 px-5">
<div
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-lg"
:style="{
backgroundColor: `${getRatingData(row.rating).color}20`,
}"
>
<span class="text-sm font-medium text-n-slate-12 truncate">
{{ $t(getRatingData(row.rating).translationKey) }}
</span>
</div>
</td>
<td class="py-4 px-5">
<span
v-if="!row.feedbackText"
class="text-n-slate-10 italic text-sm"
>
{{ $t('CSAT_REPORTS.NO_FEEDBACK') }}
</span>
<div v-else class="text-sm text-n-slate-12">
<ShowMore :text="row.feedbackText" :limit="100" />
</div>
</td>
<td class="py-4 px-5">
<UserAvatarWithName
v-if="row.assignedAgent"
:user="row.assignedAgent"
:size="28"
/>
<span v-else class="text-n-slate-10 text-sm italic">
{{ $t('CSAT_REPORTS.NO_AGENT') }}
</span>
</td>
<td v-if="showExpandableRows" class="py-4 px-5">
<div
class="p-1.5 rounded-md text-n-slate-10 group-hover:text-n-slate-12 transition-colors"
>
<i
class="size-4 block transition-transform duration-200"
:class="
isRowExpanded(row.id)
? 'i-lucide-chevron-up'
: 'i-lucide-chevron-down'
"
/>
</div>
</td>
</tr>
<tr
v-if="showExpandableRows && isRowExpanded(row.id)"
class="!border-t-0"
>
<td colspan="5" class="p-0 !border-t-0">
<CsatExpandedRow :response="row._original" />
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div v-if="metrics.totalResponseCount" class="table-pagination">
<Pagination class="mt-2" :table="table" />
<CsatEmptyState
v-else
:title="$t('CSAT_REPORTS.NO_RECORDS')"
:description="$t('CSAT_REPORTS.NO_RECORDS_DESCRIPTION')"
/>
<div
v-if="metrics.totalResponseCount"
class="px-6 py-4 border-t border-n-weak"
>
<Pagination :table="table" />
</div>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup>
defineProps({
rows: {
type: Number,
default: 5,
},
});
</script>
<template>
<div class="p-6">
<div class="space-y-4">
<div class="flex gap-4 pb-3 border-b border-n-weak">
<div class="h-4 w-32 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 w-28 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 w-20 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 flex-1 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 w-20 rounded bg-n-slate-3 animate-pulse" />
</div>
<div v-for="n in rows" :key="n" class="flex items-center gap-4 py-3">
<div class="flex items-center gap-2 w-48">
<div class="size-8 rounded-full bg-n-slate-3 animate-pulse" />
<div class="h-4 w-24 rounded bg-n-slate-3 animate-pulse" />
</div>
<div class="flex items-center gap-2 w-44">
<div class="size-8 rounded-full bg-n-slate-3 animate-pulse" />
<div class="h-4 w-20 rounded bg-n-slate-3 animate-pulse" />
</div>
<div class="h-7 w-24 rounded-lg bg-n-slate-3 animate-pulse" />
<div class="h-4 flex-1 rounded bg-n-slate-3 animate-pulse" />
<div class="h-4 w-16 rounded bg-n-slate-3 animate-pulse" />
</div>
</div>
</div>
</template>
@@ -6,7 +6,6 @@ describe('CsatMetrics.vue', () => {
let getters;
let store;
let wrapper;
const filters = { rating: 3 };
beforeEach(() => {
getters = {
@@ -18,8 +17,16 @@ describe('CsatMetrics.vue', () => {
4: 30,
5: 10,
}),
'csat/getRatingCount': () => ({
1: 10,
2: 20,
3: 30,
4: 30,
5: 10,
}),
'csat/getSatisfactionScore': () => 85,
'csat/getResponseRate': () => 90,
'csat/getUIFlags': () => ({ isFetchingMetrics: false }),
};
store = createStore({
@@ -28,40 +35,31 @@ describe('CsatMetrics.vue', () => {
wrapper = shallowMount(CsatMetrics, {
global: {
plugins: [store], // Ensure the store is injected here
plugins: [store],
mocks: {
$t: msg => msg, // mock translation function
$t: msg => msg,
},
stubs: {
CsatMetricCard: '<csat-metric-card/>',
BarChart: '<woot-horizontal-bar/>',
CsatMetricCard: true,
CsatRatingDistribution: true,
},
},
props: { filters },
});
});
it('computes response count correctly', () => {
expect(wrapper.vm.responseCount).toBe('100');
expect(wrapper.html()).toMatchSnapshot();
});
it('formats values to percent correctly', () => {
expect(wrapper.vm.formatToPercent(85)).toBe('85%');
expect(wrapper.vm.formatToPercent(null)).toBe('--');
it('renders metric cards with correct values', () => {
const metricCards = wrapper.findAllComponents({ name: 'CsatMetricCard' });
expect(metricCards).toHaveLength(3);
});
it('maps rating value to emoji correctly', () => {
const rating = wrapper.vm.csatRatings[0]; // assuming this is { value: 1, emoji: '😡' }
expect(wrapper.vm.ratingToEmoji(rating.value)).toBe(rating.emoji);
});
it('hides report card if rating filter is enabled', () => {
expect(wrapper.html()).not.toContain('bar-chart-stub');
});
it('shows report card if rating filter is not enabled', async () => {
await wrapper.setProps({ filters: {} });
expect(wrapper.html()).toContain('bar-chart-stub');
it('renders rating distribution component', () => {
const distribution = wrapper.findComponent({
name: 'CsatRatingDistribution',
});
expect(distribution.exists()).toBe(true);
});
});
@@ -1,10 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CsatMetrics.vue > computes response count correctly 1`] = `
"<div class="flex-col lg:flex-row flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-8 gap-4">
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL" infotext="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP" disabled="false" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="100"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL" infotext="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP" disabled="true" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="--"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL" infotext="CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP" disabled="false" class="xs:w-full sm:max-w-[50%] lg:w-1/6 lg:max-w-[16%]" value="90%"></csat-metric-card-stub>
<!--v-if-->
</div>"
`;
@@ -82,6 +82,9 @@ export const getters = {
),
};
},
getRatingCount(_state) {
return _state.metrics.ratingsCount;
},
};
export const actions = {
@@ -115,6 +118,13 @@ export const actions = {
});
});
},
update: async ({ commit }, { id, reviewNotes }) => {
const response = await CSATReports.update(id, {
csat_review_notes: reviewNotes,
});
commit(types.UPDATE_CSAT_RESPONSE, response.data);
return response.data;
},
};
export const mutations = {
@@ -144,6 +154,7 @@ export const mutations = {
};
_state.metrics.totalSentMessagesCount = totalSentMessagesCount || 0;
},
[types.UPDATE_CSAT_RESPONSE]: MutationHelpers.update,
};
export default {
@@ -86,4 +86,19 @@ describe('#getters', () => {
})
).toEqual('50.00');
});
it('getRatingCount', () => {
const state = {
metrics: {
ratingsCount: { 1: 10, 2: 20, 3: 15, 4: 3, 5: 2 },
},
};
expect(getters.getRatingCount(state)).toEqual({
1: 10,
2: 20,
3: 15,
4: 3,
5: 2,
});
});
});
@@ -241,6 +241,7 @@ export default {
SET_CSAT_RESPONSE_UI_FLAG: 'SET_CSAT_RESPONSE_UI_FLAG',
SET_CSAT_RESPONSE: 'SET_CSAT_RESPONSE',
SET_CSAT_RESPONSE_METRICS: 'SET_CSAT_RESPONSE_METRICS',
UPDATE_CSAT_RESPONSE: 'UPDATE_CSAT_RESPONSE',
// Custom Attributes
SET_CUSTOM_ATTRIBUTE_UI_FLAG: 'SET_CUSTOM_ATTRIBUTE_UI_FLAG',
-11
View File
@@ -1,11 +0,0 @@
export const OPEN_AI_OPTIONS = {
IMPROVE_WRITING: 'improve_writing',
FIX_SPELLING_GRAMMAR: 'fix_spelling_grammar',
SHORTEN: 'shorten',
EXPAND: 'expand',
MAKE_FRIENDLY: 'make_friendly',
MAKE_FORMAL: 'make_formal',
SIMPLIFY: 'simplify',
REPLY_SUGGESTION: 'reply_suggestion',
SUMMARIZE: 'summarize',
};
+7 -5
View File
@@ -16,10 +16,12 @@ class Conversations::ResolutionJob < ApplicationJob
private
def conversation_scope(account)
if account.auto_resolve_ignore_waiting
account.conversations.resolvable_not_waiting(account.auto_resolve_after)
else
account.conversations.resolvable_all(account.auto_resolve_after)
end
base_scope = if account.auto_resolve_ignore_waiting
account.conversations.resolvable_not_waiting(account.auto_resolve_after)
else
account.conversations.resolvable_all(account.auto_resolve_after)
end
# Exclude orphan conversations where contact was deleted but conversation cleanup is pending
base_scope.where.not(contact_id: nil)
end
end
+7
View File
@@ -34,6 +34,7 @@ class Channel::Whatsapp < ApplicationRecord
after_create :sync_templates
before_destroy :teardown_webhooks
after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
def name
'Whatsapp'
@@ -86,4 +87,10 @@ class Channel::Whatsapp < ApplicationRecord
def teardown_webhooks
Whatsapp::WebhookTeardownService.new(self).perform
end
def should_auto_setup_webhooks?
# Only auto-setup webhooks for whatsapp_cloud provider with manual setup
# Embedded signup calls setup_webhooks explicitly in EmbeddedSignupService
provider == 'whatsapp_cloud' && provider_config['source'] != 'embedded_signup'
end
end
@@ -2,17 +2,21 @@ module ConversationMuteHelpers
extend ActiveSupport::Concern
def mute!
return unless contact
resolved!
contact.update(blocked: true)
create_muted_message
end
def unmute!
return unless contact
contact.update(blocked: false)
create_unmuted_message
end
def muted?
contact.blocked?
contact&.blocked? || false
end
end
+1
View File
@@ -27,6 +27,7 @@ class CsatSurveyResponse < ApplicationRecord
belongs_to :contact
belongs_to :message
belongs_to :assigned_agent, class_name: 'User', optional: true, inverse_of: :csat_survey_responses
belongs_to :review_notes_updated_by, class_name: 'User', optional: true
validates :rating, presence: true, inclusion: { in: [1, 2, 3, 4, 5] }
validates :account_id, presence: true
+4 -7
View File
@@ -64,13 +64,10 @@ class Integrations::Hook < ApplicationRecord
update(status: 'disabled')
end
def process_event(event)
case app_id
when 'openai'
Integrations::Openai::ProcessorService.new(hook: self, event: event).perform if app_id == 'openai'
else
{ error: 'No processor found' }
end
def process_event(_event)
# OpenAI integration migrated to Captain::EditorService
# Other integrations (slack, dialogflow, etc.) handled via HookJob
{ error: 'No processor found' }
end
def feature_allowed?
+2
View File
@@ -90,6 +90,8 @@ class User < ApplicationRecord
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
alias_attribute :conversations, :assigned_conversations
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
has_many :reviewed_csat_survey_responses, foreign_key: 'review_notes_updated_by_id', class_name: 'CsatSurveyResponse',
dependent: :nullify, inverse_of: :review_notes_updated_by
has_many :conversation_participants, dependent: :destroy_async
has_many :participating_conversations, through: :conversation_participants, source: :conversation
+21
View File
@@ -0,0 +1,21 @@
class Captain::TasksPolicy < ApplicationPolicy
def rewrite?
true
end
def summarize?
true
end
def reply_suggestion?
true
end
def label_suggestion?
true
end
def follow_up?
true
end
end
+36 -1
View File
@@ -20,7 +20,7 @@ class CsatSurveyService
delegate :inbox, :contact, to: :conversation
def should_send_csat_survey?
conversation_allows_csat? && csat_enabled? && !csat_already_sent?
conversation_allows_csat? && csat_enabled? && !csat_already_sent? && csat_allowed_by_survey_rules?
end
def conversation_allows_csat?
@@ -39,6 +39,37 @@ class CsatSurveyService
conversation.can_reply?
end
def csat_allowed_by_survey_rules?
return true unless survey_rules_configured?
labels = conversation.label_list
return true if rule_values.empty?
case rule_operator
when 'contains'
rule_values.any? { |label| labels.include?(label) }
when 'does_not_contain'
rule_values.none? { |label| labels.include?(label) }
else
true
end
end
def survey_rules_configured?
return false if csat_config.blank?
return false if csat_config['survey_rules'].blank?
rule_values.any?
end
def rule_operator
csat_config.dig('survey_rules', 'operator') || 'contains'
end
def rule_values
csat_config.dig('survey_rules', 'values') || []
end
def whatsapp_channel?
inbox.channel_type == 'Channel::Whatsapp'
end
@@ -113,6 +144,10 @@ class CsatSurveyService
)
end
def csat_config
inbox.csat_config || {}
end
def send_twilio_whatsapp_template_survey
template_config = inbox.csat_config&.dig('template')
content_sid = template_config['content_sid']
@@ -26,10 +26,18 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
def build_messages(config = {})
return "No messages in this conversation\n" if @record.messages.empty?
message_text = ''
messages = @record.messages.where.not(message_type: :activity).order(created_at: :asc)
messages = @record.messages.where.not(message_type: [:activity, :template])
messages.each do |message|
if config[:token_limit]
build_limited_messages(messages, config)
else
build_all_messages(messages, config)
end
end
def build_all_messages(messages, config)
message_text = ''
messages.order(created_at: :asc).each do |message|
# Skip private messages unless explicitly included in config
next if message.private? && !config[:include_private_messages]
@@ -38,6 +46,24 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
message_text
end
def build_limited_messages(messages, config)
selected = []
character_count = 0
messages.reorder(created_at: :desc).each do |message|
# Skip private messages unless explicitly included in config
next if message.private? && !config[:include_private_messages]
formatted = format_message(message)
break if character_count + formatted.length > config[:token_limit]
selected.prepend(formatted)
character_count += formatted.length
end
selected.join
end
def format_message(message)
sender = case message.sender_type
when 'User'
@@ -2,8 +2,6 @@ class MessageTemplates::Template::CsatSurvey
pattr_initialize [:conversation!]
def perform
return unless should_send_csat_survey?
ActiveRecord::Base.transaction do
conversation.messages.create!(csat_survey_message_params)
end
@@ -13,39 +11,6 @@ class MessageTemplates::Template::CsatSurvey
delegate :contact, :account, :inbox, to: :conversation
def should_send_csat_survey?
return true unless survey_rules_configured?
labels = conversation.label_list
return true if rule_values.empty?
case rule_operator
when 'contains'
rule_values.any? { |label| labels.include?(label) }
when 'does_not_contain'
rule_values.none? { |label| labels.include?(label) }
else
true
end
end
def survey_rules_configured?
return false if csat_config.blank?
return false if csat_config['survey_rules'].blank?
return false if rule_values.empty?
true
end
def rule_operator
csat_config.dig('survey_rules', 'operator') || 'contains'
end
def rule_values
csat_config.dig('survey_rules', 'values') || []
end
def message_content
return I18n.t('conversations.templates.csat_input_message_body') if csat_config.blank? || csat_config['message'].blank?
@@ -16,6 +16,10 @@ class Whatsapp::EmbeddedSignupService
validate_token_access(access_token)
channel = create_or_reauthorize_channel(access_token, phone_info)
# NOTE: We call setup_webhooks explicitly here instead of relying on after_commit callback because:
# 1. Reauthorization flow updates an existing channel (not a create), so after_commit on: :create won't trigger
# 2. We need to run check_channel_health_and_prompt_reauth after webhook setup completes
# 3. The channel is marked with source: 'embedded_signup' to skip the after_commit callback
channel.setup_webhooks
check_channel_health_and_prompt_reauth(channel)
channel
@@ -10,10 +10,7 @@ class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageB
def download_attachment_file(attachment_payload)
url_response = HTTParty.get(
inbox.channel.media_url(
attachment_payload[:id],
inbox.channel.provider_config['phone_number_id']
),
inbox.channel.media_url(attachment_payload[:id]),
headers: inbox.channel.api_headers
)
# This url response will be failure if the access token has expired.
@@ -75,10 +75,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
csat_template_service.get_template_status(template_name)
end
def media_url(media_id, phone_number_id = nil)
url = "#{api_base_path}/v13.0/#{media_id}"
url += "?phone_number_id=#{phone_number_id}" if phone_number_id
url
def media_url(media_id)
"#{api_base_path}/v13.0/#{media_id}"
end
private
@@ -1,5 +1,5 @@
<%=
CSV.generate_line([
<%
headers = [
I18n.t('reports.csat.headers.agent_name'),
I18n.t('reports.csat.headers.rating'),
I18n.t('reports.csat.headers.feedback'),
@@ -8,24 +8,28 @@
I18n.t('reports.csat.headers.contact_phone_number'),
I18n.t('reports.csat.headers.link_to_the_conversation'),
I18n.t('reports.csat.headers.recorded_at')
])
]
headers << I18n.t('reports.csat.headers.review_notes') if ChatwootApp.enterprise?
-%>
<%= CSV.generate_line(headers) -%>
<% @csat_survey_responses.each do |csat_response| %>
<% assigned_agent = csat_response.assigned_agent %>
<% contact = csat_response.contact %>
<% conversation = csat_response.conversation %>
<%=
CSV.generate_line([
<%
row = [
assigned_agent ? "#{assigned_agent.name} (#{assigned_agent.email})" : nil,
csat_response.rating,
csat_response.feedback_message.present? ? csat_response.feedback_message : nil,
contact&.name.present? ? contact&.name: nil,
contact&.email.present? ? contact&.email: nil,
contact&.phone_number.present? ? contact&.phone_number: nil,
conversation ? app_account_conversation_url(account_id: Current.account.id, id: conversation.display_id): nil,
csat_response.created_at,
]).html_safe
csat_response.feedback_message.presence,
contact&.name.presence,
contact&.email.presence,
contact&.phone_number.presence,
conversation ? app_account_conversation_url(account_id: Current.account.id, id: conversation.display_id) : nil,
csat_response.created_at
]
row << csat_response.csat_review_notes if ChatwootApp.enterprise?
-%>
<%= CSV.generate_line(row).html_safe -%>
<% end %>
<%=
CSV.generate_line([
@@ -0,0 +1 @@
json.partial! 'api/v1/models/csat_survey_response', formats: [:json], resource: @csat_survey_response
@@ -1,6 +1,14 @@
json.id resource.id
json.rating resource.rating
json.feedback_message resource.feedback_message
json.csat_review_notes resource.csat_review_notes
json.review_notes_updated_at resource.review_notes_updated_at&.to_i
if resource.review_notes_updated_by
json.review_notes_updated_by do
json.id resource.review_notes_updated_by.id
json.name resource.review_notes_updated_by.name
end
end
json.account_id resource.account_id
json.message_id resource.message_id
if resource.contact
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.9.2'
version: '4.10.1'
development:
<<: *shared
+7
View File
@@ -230,3 +230,10 @@
- name: channel_tiktok
display_name: TikTok Channel
enabled: true
- name: csat_review_notes
display_name: CSAT Review Notes
enabled: false
premium: true
- name: captain_tasks
display_name: Captain Tasks
enabled: true
+4
View File
@@ -202,6 +202,7 @@ en:
rating: Rating
feedback: Feedback Comment
recorded_at: Recorded date
review_notes: Review Notes
notifications:
notification_title:
conversation_creation: 'A conversation (#%{display_id}) has been created in %{inbox_name}'
@@ -344,6 +345,9 @@ en:
copilot_message_required: Message is required
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
upgrade: 'Upgrade your plan to enable Captain AI'
disabled: 'Captain AI is disabled for this account.'
api_key_missing: 'Captain AI API key is not configured.'
copilot:
using_tool: 'Using tool %{function_name}'
completed_tool_call: 'Completed %{function_name} tool call'
+10
View File
@@ -73,6 +73,13 @@ Rails.application.routes.draw do
end
resources :custom_tools
resources :documents, only: [:index, :show, :create, :destroy]
resource :tasks, only: [], controller: 'tasks' do
post :rewrite
post :summarize
post :reply_suggestion
post :label_suggestion
post :follow_up
end
end
resource :saml_settings, only: [:show, :create, :update, :destroy]
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
@@ -189,6 +196,9 @@ Rails.application.routes.draw do
get :metrics
get :download
end
member do
patch :update if ChatwootApp.enterprise?
end
end
resources :applied_slas, only: [:index] do
collection do
@@ -0,0 +1,5 @@
class AddInternalObservationsToCsatSurveyResponses < ActiveRecord::Migration[7.1]
def change
add_column :csat_survey_responses, :csat_review_notes, :text
end
end
@@ -0,0 +1,6 @@
class AddObservationsAuditToCsatSurveyResponses < ActiveRecord::Migration[7.1]
def change
add_column :csat_survey_responses, :review_notes_updated_at, :datetime
add_reference :csat_survey_responses, :review_notes_updated_by, index: true
end
end
@@ -0,0 +1,12 @@
# Enable captain_tasks for existing accounts.
# Unlike 20250416182131_flip_chatwoot_v4_default_feature_flag_installation_config.rb,
# we don't need to update ACCOUNT_LEVEL_FEATURE_DEFAULTS or clear GlobalConfig cache
# because captain_tasks already has `enabled: true` in features.yml - ConfigLoader
# handles the defaults on deploy automatically.
class EnableCaptainTasksForExistingAccounts < ActiveRecord::Migration[7.0]
def up
Account.find_in_batches(batch_size: 100) do |accounts|
accounts.each { |account| account.enable_features!('captain_tasks') }
end
end
end
+5 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_01_12_092041) do
ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -733,11 +733,15 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_12_092041) do
t.bigint "assigned_agent_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "csat_review_notes"
t.datetime "review_notes_updated_at"
t.bigint "review_notes_updated_by_id"
t.index ["account_id"], name: "index_csat_survey_responses_on_account_id"
t.index ["assigned_agent_id"], name: "index_csat_survey_responses_on_assigned_agent_id"
t.index ["contact_id"], name: "index_csat_survey_responses_on_contact_id"
t.index ["conversation_id"], name: "index_csat_survey_responses_on_conversation_id"
t.index ["message_id"], name: "index_csat_survey_responses_on_message_id", unique: true
t.index ["review_notes_updated_by_id"], name: "index_csat_survey_responses_on_review_notes_updated_by_id"
end
create_table "custom_attribute_definitions", force: :cascade do |t|
+6 -6
View File
@@ -2,7 +2,7 @@
FROM node:24-alpine as node
FROM ruby:3.4.4-alpine3.21 AS pre-builder
ARG NODE_VERSION="24.12.0"
ARG NODE_VERSION="24.13.0"
ARG PNPM_VERSION="10.2.0"
ENV NODE_VERSION=${NODE_VERSION}
ENV PNPM_VERSION=${PNPM_VERSION}
@@ -11,7 +11,7 @@ ENV PNPM_VERSION=${PNPM_VERSION}
# For development docker-compose file overrides ARGS
ARG BUNDLE_WITHOUT="development:test"
ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT}
ENV BUNDLER_VERSION=2.5.11
ENV BUNDLER_VERSION=2.5.16
ARG RAILS_SERVE_STATIC_FILES=true
ENV RAILS_SERVE_STATIC_FILES ${RAILS_SERVE_STATIC_FILES}
@@ -35,7 +35,7 @@ RUN apk update && apk add --no-cache \
curl \
xz \
&& mkdir -p /var/app \
&& gem install bundler
&& gem install bundler -v "$BUNDLER_VERSION"
COPY --from=node /usr/local/bin/node /usr/local/bin/
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
@@ -98,14 +98,14 @@ RUN rm -rf /gems/ruby/3.4.0/cache/*.gem \
# final build stage
FROM ruby:3.4.4-alpine3.21
ARG NODE_VERSION="24.12.0"
ARG NODE_VERSION="24.13.0"
ARG PNPM_VERSION="10.2.0"
ENV NODE_VERSION=${NODE_VERSION}
ENV PNPM_VERSION=${PNPM_VERSION}
ARG BUNDLE_WITHOUT="development:test"
ENV BUNDLE_WITHOUT ${BUNDLE_WITHOUT}
ENV BUNDLER_VERSION=2.5.11
ENV BUNDLER_VERSION=2.5.16
ARG EXECJS_RUNTIME="Disabled"
ENV EXECJS_RUNTIME ${EXECJS_RUNTIME}
@@ -128,7 +128,7 @@ RUN apk update && apk add --no-cache \
imagemagick \
git \
vips \
&& gem install bundler
&& gem install bundler -v "$BUNDLER_VERSION"
COPY --from=node /usr/local/bin/node /usr/local/bin/
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
@@ -0,0 +1,71 @@
class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseController
before_action :check_authorization
def rewrite
result = Captain::RewriteService.new(
account: Current.account,
content: params[:content],
operation: params[:operation],
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def summarize
result = Captain::SummaryService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def reply_suggestion
result = Captain::ReplySuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id],
user: Current.user
).perform
render_result(result)
end
def label_suggestion
result = Captain::LabelSuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def follow_up
result = Captain::FollowUpService.new(
account: Current.account,
follow_up_context: params[:follow_up_context]&.to_unsafe_h,
user_message: params[:message],
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
private
def render_result(result)
if result.nil?
render json: { message: nil }
elsif result[:error]
render json: { error: result[:error] }, status: :unprocessable_entity
else
response_data = { message: result[:message] }
response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
render json: response_data
end
end
def check_authorization
authorize(:'captain/tasks')
end
end
@@ -0,0 +1,12 @@
module Enterprise::Api::V1::Accounts::CsatSurveyResponsesController
def update
@csat_survey_response = Current.account.csat_survey_responses.find(params[:id])
authorize @csat_survey_response
@csat_survey_response.update!(
csat_review_notes: params[:csat_review_notes],
review_notes_updated_by: Current.user,
review_notes_updated_at: Time.current
)
end
end
@@ -10,4 +10,8 @@ module Enterprise::CsatSurveyResponsePolicy
def download?
@account_user.custom_role&.permissions&.include?('report_manage') || super
end
def update?
@account_user.administrator? || @account_user.custom_role&.permissions&.include?('report_manage')
end
end

Some files were not shown because too many files have changed in this diff Show More