Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28778e3ae2 | ||
|
|
885b041a83 | ||
|
|
ad2329c237 | ||
|
|
747d451387 | ||
|
|
8eb6fd1bff | ||
|
|
75f75ce786 | ||
|
|
964d2f8544 | ||
|
|
1f5fdd7199 | ||
|
|
9e97cc0cdd | ||
|
|
70d09fcc66 | ||
|
|
cc5ec833dc | ||
|
|
f84e95ed6c | ||
|
|
6a482926b4 | ||
|
|
c77c9c9d8a | ||
|
|
ecd4892a23 | ||
|
|
457430e8d9 | ||
|
|
e13e3c873a | ||
|
|
0346e9a2c7 | ||
|
|
7e4d93f649 | ||
|
|
b2ffad1998 |
+1
-1
@@ -1 +1 @@
|
||||
4.10.0
|
||||
4.10.1
|
||||
|
||||
@@ -24,9 +24,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
def search
|
||||
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
|
||||
|
||||
contacts = resolved_contacts.where(
|
||||
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
|
||||
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
|
||||
contacts = Current.account.contacts.where(
|
||||
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
|
||||
search: "%#{params[:q].strip}%"
|
||||
)
|
||||
@contacts = fetch_contacts(contacts)
|
||||
|
||||
@@ -110,6 +110,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def update_last_seen
|
||||
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
|
||||
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
|
||||
# Always update immediately if there are unread messages to maintain accurate read/unread state.
|
||||
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
|
||||
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
|
||||
|
||||
# No unread messages - apply throttling to limit DB writes
|
||||
return unless should_update_last_seen?
|
||||
|
||||
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
|
||||
end
|
||||
|
||||
@@ -142,12 +151,25 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def update_last_seen_on_conversation(last_seen_at, update_assignee)
|
||||
updates = { agent_last_seen_at: last_seen_at }
|
||||
updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present?
|
||||
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
@conversation.update_column(:agent_last_seen_at, last_seen_at)
|
||||
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
|
||||
@conversation.update_columns(updates)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
def should_update_last_seen?
|
||||
# Update if at least one relevant timestamp is older than 1 hour or not set
|
||||
# This prevents redundant DB writes when agents repeatedly view the same conversation
|
||||
agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago
|
||||
return agent_needs_update unless assignee?
|
||||
|
||||
# For assignees, check both timestamps - update if either is old
|
||||
assignee_needs_update = @conversation.assignee_last_seen_at.blank? || @conversation.assignee_last_seen_at < 1.hour.ago
|
||||
agent_needs_update || assignee_needs_update
|
||||
end
|
||||
|
||||
def set_conversation_status
|
||||
@conversation.status = params[:status]
|
||||
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
+1
-1
@@ -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 { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
|
||||
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: {
|
||||
@@ -62,21 +60,18 @@ export default {
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
const {
|
||||
isAIIntegrationEnabled,
|
||||
captainTasksEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
} = useAI();
|
||||
getLabelSuggestions,
|
||||
} = useLabelSuggestions();
|
||||
|
||||
provide('contextMenuElementTarget', conversationPanelRef);
|
||||
|
||||
return {
|
||||
isEnterprise,
|
||||
isPopOutReplyBox,
|
||||
isAIIntegrationEnabled,
|
||||
captainTasksEnabled,
|
||||
getLabelSuggestions,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
conversationPanelRef,
|
||||
};
|
||||
},
|
||||
@@ -105,8 +100,8 @@ export default {
|
||||
shouldShowLabelSuggestions() {
|
||||
return (
|
||||
this.isOpen &&
|
||||
this.isEnterprise &&
|
||||
this.isAIIntegrationEnabled &&
|
||||
this.captainTasksEnabled &&
|
||||
this.isLabelSuggestionFeatureEnabled &&
|
||||
!this.messageSentSinceOpened
|
||||
);
|
||||
},
|
||||
@@ -291,24 +286,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 || !this.isLabelSuggestionFeatureEnabled) {
|
||||
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.reset"
|
||||
/>
|
||||
</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
@@ -515,6 +515,10 @@ const languages = [
|
||||
name: 'Portuguese',
|
||||
id: 'pt',
|
||||
},
|
||||
{
|
||||
name: 'Portuguese (Brazil)',
|
||||
id: 'pt_BR',
|
||||
},
|
||||
{
|
||||
name: 'Punjabi',
|
||||
id: 'pa',
|
||||
|
||||
+7
-7
@@ -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
|
||||
@@ -13,7 +13,7 @@ import { mapGetters } from 'vuex';
|
||||
// utils & constants
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { OPEN_AI_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
import { CAPTAIN_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
name: 'LabelSuggestion',
|
||||
@@ -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;
|
||||
},
|
||||
@@ -114,7 +114,7 @@ export default {
|
||||
|
||||
// dismiss this once the values are set
|
||||
this.isDismissed = true;
|
||||
this.trackLabelEvent(OPEN_AI_EVENTS.DISMISS_LABEL_SUGGESTION);
|
||||
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_DISMISSED);
|
||||
},
|
||||
isConversationDismissed() {
|
||||
return LocalStorage.getFlag(
|
||||
@@ -132,7 +132,7 @@ export default {
|
||||
conversationId: this.conversationId,
|
||||
labels: labelsToAdd,
|
||||
});
|
||||
this.trackLabelEvent(OPEN_AI_EVENTS.APPLY_LABEL_SUGGESTION);
|
||||
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_APPLIED);
|
||||
},
|
||||
trackLabelEvent(event) {
|
||||
const payload = {
|
||||
|
||||
@@ -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,172 @@
|
||||
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';
|
||||
|
||||
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', () => ({
|
||||
CAPTAIN_EVENTS: {
|
||||
TEST_EVENT: 'captain_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('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();
|
||||
});
|
||||
});
|
||||
@@ -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,41 @@
|
||||
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 } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
|
||||
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 +44,6 @@ export function useCaptain() {
|
||||
if (captainLimits.value?.documents) {
|
||||
return useCamelCase(captainLimits.value.documents);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -31,7 +51,6 @@ export function useCaptain() {
|
||||
if (captainLimits.value?.responses) {
|
||||
return useCamelCase(captainLimits.value.responses);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -43,12 +62,159 @@ 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);
|
||||
};
|
||||
|
||||
// === 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: '' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
followUp,
|
||||
processEvent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
// Actions that map to REWRITE events (with operation attribute)
|
||||
const REWRITE_ACTIONS = [
|
||||
'improve',
|
||||
'fix_spelling_grammar',
|
||||
'casual',
|
||||
'professional',
|
||||
'expand',
|
||||
'shorten',
|
||||
'rephrase',
|
||||
'make_friendly',
|
||||
'make_formal',
|
||||
'simplify',
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets the event key suffix based on action type.
|
||||
* @param {string} action - The action type
|
||||
* @returns {string} The event key prefix (REWRITE, SUMMARIZE, or REPLY_SUGGESTION)
|
||||
*/
|
||||
function getEventPrefix(action) {
|
||||
if (action === 'summarize') return 'SUMMARIZE';
|
||||
if (action === 'reply_suggestion') return 'REPLY_SUGGESTION';
|
||||
return 'REWRITE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the analytics payload based on action type.
|
||||
* @param {string} action - The action type
|
||||
* @param {number} conversationId - The conversation ID
|
||||
* @param {number} [followUpCount] - Optional follow-up count
|
||||
* @returns {Object} The payload object
|
||||
*/
|
||||
function buildPayload(action, conversationId, followUpCount = undefined) {
|
||||
const payload = { conversationId };
|
||||
|
||||
// Add operation for rewrite actions
|
||||
if (REWRITE_ACTIONS.includes(action)) {
|
||||
payload.operation = action;
|
||||
}
|
||||
|
||||
// Add followUpCount if provided
|
||||
if (followUpCount !== undefined) {
|
||||
payload.followUpCount = followUpCount;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, currentChat } = 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);
|
||||
|
||||
// Tracking state
|
||||
const currentAction = ref(null);
|
||||
const followUpCount = ref(0);
|
||||
const trackedConversationId = ref(null);
|
||||
|
||||
const conversationId = computed(() => currentChat.value?.id);
|
||||
|
||||
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.
|
||||
* @param {boolean} [trackDismiss=true] - Whether to track dismiss event
|
||||
*/
|
||||
function reset(trackDismiss = true) {
|
||||
// Track dismiss event if there was content and we're not accepting
|
||||
if (trackDismiss && generatedContent.value && currentAction.value) {
|
||||
const eventKey = `${getEventPrefix(currentAction.value)}_DISMISSED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(
|
||||
currentAction.value,
|
||||
trackedConversationId.value,
|
||||
followUpCount.value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (abortController.value) {
|
||||
abortController.value.abort();
|
||||
abortController.value = null;
|
||||
}
|
||||
showEditor.value = false;
|
||||
isGenerating.value = false;
|
||||
isContentReady.value = false;
|
||||
generatedContent.value = '';
|
||||
followUpContext.value = null;
|
||||
currentAction.value = null;
|
||||
followUpCount.value = 0;
|
||||
trackedConversationId.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 without tracking dismiss (starting new action)
|
||||
reset(false);
|
||||
abortController.value = new AbortController();
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
currentAction.value = action;
|
||||
followUpCount.value = 0;
|
||||
trackedConversationId.value = conversationId.value;
|
||||
|
||||
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;
|
||||
// Track "Used" event on successful generation
|
||||
const eventKey = `${getEventPrefix(action)}_USED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(action, trackedConversationId.value)
|
||||
);
|
||||
}
|
||||
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;
|
||||
|
||||
// Track follow-up sent event
|
||||
useTrack(CAPTAIN_EVENTS.FOLLOW_UP_SENT, {
|
||||
conversationId: trackedConversationId.value,
|
||||
});
|
||||
followUpCount.value += 1;
|
||||
|
||||
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;
|
||||
|
||||
// Track "Applied" event
|
||||
if (currentAction.value) {
|
||||
const eventKey = `${getEventPrefix(currentAction.value)}_APPLIED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(
|
||||
currentAction.value,
|
||||
trackedConversationId.value,
|
||||
followUpCount.value
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Reset state without tracking dismiss
|
||||
showEditor.value = false;
|
||||
generatedContent.value = '';
|
||||
followUpContext.value = null;
|
||||
currentAction.value = null;
|
||||
followUpCount.value = 0;
|
||||
trackedConversationId.value = null;
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
return {
|
||||
showEditor,
|
||||
isGenerating,
|
||||
isContentReady,
|
||||
generatedContent,
|
||||
followUpContext,
|
||||
|
||||
isActive,
|
||||
isButtonDisabled,
|
||||
editorTransitionKey,
|
||||
|
||||
reset,
|
||||
toggleEditor,
|
||||
setContentReady,
|
||||
execute,
|
||||
sendFollowUp,
|
||||
accept,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
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 useLabelSuggestions() {
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const conversationId = computed(() => currentChat.value?.id);
|
||||
|
||||
const captainTasksEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
|
||||
});
|
||||
|
||||
const aiIntegration = computed(
|
||||
() =>
|
||||
appIntegrations.value.find(
|
||||
integration => integration.id === 'openai' && !!integration.hooks.length
|
||||
)?.hooks[0]
|
||||
);
|
||||
|
||||
const isLabelSuggestionFeatureEnabled = computed(() => {
|
||||
if (aiIntegration.value) {
|
||||
const { settings = {} } = aiIntegration.value || {};
|
||||
return !!settings.label_suggestion;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const fetchIntegrationsIfRequired = async () => {
|
||||
if (!appIntegrations.value.length) {
|
||||
await store.dispatch('integrations/get');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 [];
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchIntegrationsIfRequired();
|
||||
});
|
||||
|
||||
return {
|
||||
captainTasksEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
getLabelSuggestions,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -34,7 +34,8 @@ export default {
|
||||
DOCS_URL: 'https://www.chatwoot.com/docs/product/',
|
||||
HELP_CENTER_DOCS_URL:
|
||||
'https://www.chatwoot.com/docs/product/others/help-center',
|
||||
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
|
||||
TESTIMONIAL_URL:
|
||||
'https://testimonials.cdn.chatwoot.com/testimonial-content.json',
|
||||
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
|
||||
'https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations',
|
||||
SMALL_SCREEN_BREAKPOINT: 768,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -84,21 +84,28 @@ export const PORTALS_EVENTS = Object.freeze({
|
||||
PREVIEW_ARTICLE: 'Previewed article',
|
||||
});
|
||||
|
||||
export const OPEN_AI_EVENTS = Object.freeze({
|
||||
SUMMARIZE: 'OpenAI: Used summarize',
|
||||
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
|
||||
REPHRASE: 'OpenAI: Used rephrase',
|
||||
FIX_SPELLING_AND_GRAMMAR: 'OpenAI: Used fix spelling and grammar',
|
||||
SHORTEN: 'OpenAI: Used shorten',
|
||||
EXPAND: 'OpenAI: Used expand',
|
||||
MAKE_FRIENDLY: 'OpenAI: Used make friendly',
|
||||
MAKE_FORMAL: 'OpenAI: Used make formal',
|
||||
SIMPLIFY: 'OpenAI: Used simplify',
|
||||
APPLY_LABEL_SUGGESTION: 'OpenAI: Apply label from suggestion',
|
||||
DISMISS_LABEL_SUGGESTION: 'OpenAI: Dismiss label suggestions',
|
||||
ADDED_AI_INTEGRATION_VIA_CTA_BUTTON:
|
||||
'OpenAI: Added AI integration via CTA button',
|
||||
DISMISS_AI_SUGGESTION: 'OpenAI: Dismiss AI suggestions',
|
||||
export const CAPTAIN_EVENTS = Object.freeze({
|
||||
// Rewrite events (with operation attribute in payload)
|
||||
REWRITE_USED: 'Captain: Rewrite used',
|
||||
REWRITE_APPLIED: 'Captain: Rewrite applied',
|
||||
REWRITE_DISMISSED: 'Captain: Rewrite dismissed',
|
||||
|
||||
// Summarize events
|
||||
SUMMARIZE_USED: 'Captain: Summarize used',
|
||||
SUMMARIZE_APPLIED: 'Captain: Summarize applied',
|
||||
SUMMARIZE_DISMISSED: 'Captain: Summarize dismissed',
|
||||
|
||||
// Reply suggestion events
|
||||
REPLY_SUGGESTION_USED: 'Captain: Reply suggestion used',
|
||||
REPLY_SUGGESTION_APPLIED: 'Captain: Reply suggestion applied',
|
||||
REPLY_SUGGESTION_DISMISSED: 'Captain: Reply suggestion dismissed',
|
||||
|
||||
// Follow-up events
|
||||
FOLLOW_UP_SENT: 'Captain: Follow-up sent',
|
||||
|
||||
// Label suggestions
|
||||
LABEL_SUGGESTION_APPLIED: 'Captain: Label suggestion applied',
|
||||
LABEL_SUGGESTION_DISMISSED: 'Captain: Label suggestion dismissed',
|
||||
});
|
||||
|
||||
export const COPILOT_EVENTS = Object.freeze({
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,7 +825,8 @@
|
||||
"PENDING": "Pending WhatsApp approval",
|
||||
"REJECTED": "Meta rejected the template",
|
||||
"DEFAULT": "Needs WhatsApp approval",
|
||||
"NOT_FOUND": "The template does not exist in the Meta platform."
|
||||
"NOT_FOUND": "The template does not exist in the Meta platform.",
|
||||
"VIEW_TEMPLATE": "View in WhatsApp Manager"
|
||||
},
|
||||
"TEMPLATE_CREATION": {
|
||||
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"REGISTER": {
|
||||
"TRY_WOOT": "Create an account",
|
||||
"GET_STARTED": "Get started with Chatwoot",
|
||||
"TITLE": "Register",
|
||||
"TESTIMONIAL_HEADER": "All it takes is one step to move forward",
|
||||
"TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.",
|
||||
|
||||
@@ -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
-3
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
import ButtonV4 from 'next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { META_BUSINESS_URL } from 'shared/constants/links';
|
||||
|
||||
const props = defineProps({
|
||||
healthData: {
|
||||
@@ -109,12 +110,11 @@ const handleGoToSettings = () => {
|
||||
|
||||
if (businessId) {
|
||||
// WhatsApp Business Manager URL with specific business ID and phone numbers tab
|
||||
const whatsappBusinessUrl = `https://business.facebook.com/latest/whatsapp_manager/phone_numbers/?business_id=${businessId}&tab=phone-numbers`;
|
||||
const whatsappBusinessUrl = `${META_BUSINESS_URL}/latest/whatsapp_manager/phone_numbers/?business_id=${businessId}&tab=phone-numbers`;
|
||||
window.open(whatsappBusinessUrl, '_blank');
|
||||
} else {
|
||||
// Fallback to general WhatsApp Business Manager if business_id is not available
|
||||
const fallbackUrl = 'https://business.facebook.com/';
|
||||
window.open(fallbackUrl, '_blank');
|
||||
window.open(META_BUSINESS_URL, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+25
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
|
||||
import { META_BUSINESS_URL } from 'shared/constants/links';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
@@ -91,6 +92,21 @@ const shouldShowTemplateStatus = computed(
|
||||
() => templateStatus.value && !templateLoading.value
|
||||
);
|
||||
|
||||
const templateDetailsUrl = computed(() => {
|
||||
if (
|
||||
!isAWhatsAppChannel.value ||
|
||||
!templateStatus.value?.template_exists ||
|
||||
!templateStatus.value?.template_id ||
|
||||
!templateStatus.value?.business_account_id
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const businessId = templateStatus.value.business_account_id;
|
||||
const templateId = templateStatus.value.template_id;
|
||||
return `${META_BUSINESS_URL}/latest/whatsapp_manager/template_details?business_id=${businessId}&id=${templateId}`;
|
||||
});
|
||||
|
||||
const templateApprovalStatus = computed(() => {
|
||||
const statusMap = {
|
||||
APPROVED: {
|
||||
@@ -488,6 +504,15 @@ const handleConfirmTemplateUpdate = async () => {
|
||||
>
|
||||
{{ templateApprovalStatus.text }}
|
||||
</span>
|
||||
<a
|
||||
v-if="templateDetailsUrl"
|
||||
:href="templateDetailsUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm font-medium text-n-blue-text hover:underline"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.VIEW_TEMPLATE') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,13 +14,22 @@ export const getHeadingsfromTheArticle = () => {
|
||||
const rows = [];
|
||||
const articleElement = document.getElementById('cw-article-content');
|
||||
articleElement.querySelectorAll('h1, h2, h3').forEach(element => {
|
||||
const slug = slugifyWithCounter(element.innerText);
|
||||
const headingText = element.innerText;
|
||||
const slug = slugifyWithCounter(headingText);
|
||||
element.id = slug;
|
||||
element.className = 'scroll-mt-24 heading';
|
||||
element.innerHTML += `<a class="permalink text-slate-600 ml-3" href="#${slug}" title="${element.innerText}" data-turbolinks="false">#</a>`;
|
||||
|
||||
const permalink = document.createElement('a');
|
||||
permalink.className = 'permalink text-slate-600 ml-3';
|
||||
permalink.href = `#${slug}`;
|
||||
permalink.title = headingText;
|
||||
permalink.dataset.turbolinks = 'false';
|
||||
permalink.textContent = '#';
|
||||
element.appendChild(permalink);
|
||||
|
||||
rows.push({
|
||||
slug,
|
||||
title: element.innerText,
|
||||
title: headingText,
|
||||
tag: element.tagName.toLowerCase(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,3 +9,5 @@ export const REPLY_POLICY = {
|
||||
};
|
||||
|
||||
export const CHANGELOG_API_URL = 'https://hub.2.chatwoot.com/changelogs';
|
||||
|
||||
export const META_BUSINESS_URL = 'https://business.facebook.com';
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
clearLocalStorageOnLogout,
|
||||
} from 'dashboard/store/utils/api';
|
||||
import wootAPI from './apiClient';
|
||||
import { getLoginRedirectURL } from '../helpers/AuthHelper';
|
||||
import {
|
||||
getLoginRedirectURL,
|
||||
getCredentialsFromEmail,
|
||||
} from '../helpers/AuthHelper';
|
||||
|
||||
export const login = async ({
|
||||
ssoAccountId,
|
||||
@@ -46,9 +49,10 @@ export const login = async ({
|
||||
|
||||
export const register = async creds => {
|
||||
try {
|
||||
const { fullName, accountName } = getCredentialsFromEmail(creds.email);
|
||||
const response = await wootAPI.post('api/v1/accounts.json', {
|
||||
account_name: creds.accountName.trim(),
|
||||
user_full_name: creds.fullName.trim(),
|
||||
account_name: accountName,
|
||||
user_full_name: fullName,
|
||||
email: creds.email,
|
||||
password: creds.password,
|
||||
h_captcha_client_response: creds.hCaptchaClientResponse,
|
||||
|
||||
@@ -22,6 +22,21 @@ const getSSOAccountPath = ({ ssoAccountId, user }) => {
|
||||
return accountPath;
|
||||
};
|
||||
|
||||
const capitalize = str =>
|
||||
str
|
||||
.split(/[._-]+/)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
export const getCredentialsFromEmail = email => {
|
||||
const [localPart, domain] = email.split('@');
|
||||
const namePart = localPart.split('+')[0];
|
||||
return {
|
||||
fullName: capitalize(namePart),
|
||||
accountName: capitalize(domain.split('.')[0]),
|
||||
};
|
||||
};
|
||||
|
||||
export const getLoginRedirectURL = ({
|
||||
ssoAccountId,
|
||||
ssoConversationId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getLoginRedirectURL } from '../AuthHelper';
|
||||
import { getLoginRedirectURL, getCredentialsFromEmail } from '../AuthHelper';
|
||||
|
||||
describe('#URL Helpers', () => {
|
||||
describe('getLoginRedirectURL', () => {
|
||||
@@ -40,4 +40,41 @@ describe('#URL Helpers', () => {
|
||||
expect(getLoginRedirectURL('7500', null)).toBe('/app/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCredentialsFromEmail', () => {
|
||||
it('should capitalize fullName and accountName from a standard email', () => {
|
||||
expect(getCredentialsFromEmail('john@company.com')).toEqual({
|
||||
fullName: 'John',
|
||||
accountName: 'Company',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle subdomains by using the first part of the domain', () => {
|
||||
expect(getCredentialsFromEmail('jane@mail.example.org')).toEqual({
|
||||
fullName: 'Jane',
|
||||
accountName: 'Mail',
|
||||
});
|
||||
});
|
||||
|
||||
it('should split by dots and capitalize each word', () => {
|
||||
expect(getCredentialsFromEmail('john.doe@acme.co')).toEqual({
|
||||
fullName: 'John Doe',
|
||||
accountName: 'Acme',
|
||||
});
|
||||
});
|
||||
|
||||
it('should omit everything after + in the local part', () => {
|
||||
expect(getCredentialsFromEmail('user+tag@startup.io')).toEqual({
|
||||
fullName: 'User',
|
||||
accountName: 'Startup',
|
||||
});
|
||||
});
|
||||
|
||||
it('should split by underscores and hyphens', () => {
|
||||
expect(getCredentialsFromEmail('first_last@my-company.com')).toEqual({
|
||||
fullName: 'First Last',
|
||||
accountName: 'My Company',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,83 +1,84 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
<script setup>
|
||||
import { ref, computed, onBeforeMount } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import SignupForm from './components/Signup/Form.vue';
|
||||
import Testimonials from './components/Testimonials/Index.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import signupBg from 'assets/images/auth/signup-bg.jpg';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SignupForm,
|
||||
Spinner,
|
||||
Testimonials,
|
||||
},
|
||||
setup() {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
return { replaceInstallationName };
|
||||
},
|
||||
data() {
|
||||
return { isLoading: false };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ globalConfig: 'globalConfig/get' }),
|
||||
isAChatwootInstance() {
|
||||
return this.globalConfig.installationName === 'Chatwoot';
|
||||
},
|
||||
},
|
||||
beforeMount() {
|
||||
this.isLoading = this.isAChatwootInstance;
|
||||
},
|
||||
methods: {
|
||||
resizeContainers() {
|
||||
this.isLoading = false;
|
||||
},
|
||||
},
|
||||
const store = useStore();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const globalConfig = computed(() => store.getters['globalConfig/get']);
|
||||
const isAChatwootInstance = computed(
|
||||
() => globalConfig.value.installationName === 'Chatwoot'
|
||||
);
|
||||
|
||||
onBeforeMount(() => {
|
||||
isLoading.value = isAChatwootInstance.value;
|
||||
});
|
||||
|
||||
const resizeContainers = () => {
|
||||
isLoading.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full bg-n-background">
|
||||
<div v-show="!isLoading" class="flex h-full min-h-screen items-center">
|
||||
<div
|
||||
class="flex-1 min-h-[640px] inline-flex items-center h-full justify-center overflow-auto py-6"
|
||||
>
|
||||
<div class="px-8 max-w-[560px] w-full overflow-auto">
|
||||
<div class="mb-4">
|
||||
<div
|
||||
class="relative w-full h-full min-h-screen flex items-center justify-center bg-cover bg-center bg-no-repeat p-4"
|
||||
:style="{ backgroundImage: `url(${signupBg})` }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-n-gray-12/60 dark:bg-n-gray-1/80 backdrop-blur-sm"
|
||||
/>
|
||||
<div
|
||||
v-show="!isLoading"
|
||||
class="relative flex max-w-[960px] bg-white dark:bg-n-solid-2 rounded-lg outline outline-1 outline-n-container shadow-sm"
|
||||
:class="{ 'w-auto xl:w-full': isAChatwootInstance }"
|
||||
>
|
||||
<div class="flex-1 flex items-center justify-center py-10 px-10">
|
||||
<div class="max-w-[420px] w-full">
|
||||
<div class="mb-6">
|
||||
<img
|
||||
:src="globalConfig.logo"
|
||||
:alt="globalConfig.installationName"
|
||||
class="block w-auto h-8 dark:hidden"
|
||||
class="block w-auto h-7 dark:hidden"
|
||||
/>
|
||||
<img
|
||||
v-if="globalConfig.logoDark"
|
||||
:src="globalConfig.logoDark"
|
||||
:alt="globalConfig.installationName"
|
||||
class="hidden w-auto h-8 dark:block"
|
||||
class="hidden w-auto h-7 dark:block"
|
||||
/>
|
||||
<h2
|
||||
class="mt-6 text-3xl font-medium text-left mb-7 text-n-slate-12"
|
||||
>
|
||||
{{ $t('REGISTER.TRY_WOOT') }}
|
||||
<h2 class="mt-6 text-2xl font-semibold text-n-slate-12">
|
||||
{{
|
||||
isAChatwootInstance
|
||||
? $t('REGISTER.GET_STARTED')
|
||||
: $t('REGISTER.TRY_WOOT')
|
||||
}}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-n-slate-11">
|
||||
{{ $t('REGISTER.HAVE_AN_ACCOUNT') }}{{ ' '
|
||||
}}<router-link
|
||||
class="text-n-blue-10 font-medium hover:text-n-blue-11"
|
||||
to="/app/login"
|
||||
>
|
||||
{{ $t('LOGIN.SUBMIT') }}
|
||||
</router-link>
|
||||
</p>
|
||||
</div>
|
||||
<SignupForm />
|
||||
<div class="px-1 text-sm text-n-slate-12">
|
||||
<span>{{ $t('REGISTER.HAVE_AN_ACCOUNT') }} </span>
|
||||
<router-link class="text-link text-n-brand" to="/app/login">
|
||||
{{ replaceInstallationName($t('LOGIN.TITLE')) }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Testimonials
|
||||
v-if="isAChatwootInstance"
|
||||
class="flex-1"
|
||||
class="flex-1 hidden xl:flex"
|
||||
@resize-containers="resizeContainers"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-show="isLoading"
|
||||
class="flex items-center min-h-screen justify-center w-full h-full"
|
||||
class="relative flex items-center justify-center w-full h-full"
|
||||
>
|
||||
<Spinner color-scheme="primary" size="" />
|
||||
</div>
|
||||
|
||||
@@ -1,224 +1,121 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed, reactive } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, email, sameAs } from '@vuelidate/validators';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { required, minLength, email } from '@vuelidate/validators';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
|
||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import SimpleDivider from '../../../../../components/Divider/SimpleDivider.vue';
|
||||
import FormInput from '../../../../../components/Form/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import PasswordRequirements from './PasswordRequirements.vue';
|
||||
import { isValidPassword } from 'shared/helpers/Validators';
|
||||
import GoogleOAuthButton from '../../../../../components/GoogleOauth/Button.vue';
|
||||
import { register } from '../../../../../api/auth';
|
||||
import * as CompanyEmailValidator from 'company-email-validator';
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 6;
|
||||
const SPECIAL_CHAR_REGEX = /[!@#$%^&*()_+\-=[\]{}|'"/\\.,`<>:;?~]/;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FormInput,
|
||||
GoogleOAuthButton,
|
||||
NextButton,
|
||||
SimpleDivider,
|
||||
Icon,
|
||||
VueHcaptcha,
|
||||
},
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
credentials: {
|
||||
accountName: '',
|
||||
fullName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
hCaptchaClientResponse: '',
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const hCaptcha = ref(null);
|
||||
const isPasswordFocused = ref(false);
|
||||
const isSignupInProgress = ref(false);
|
||||
|
||||
const credentials = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
hCaptchaClientResponse: '',
|
||||
});
|
||||
|
||||
const rules = {
|
||||
credentials: {
|
||||
email: {
|
||||
required,
|
||||
email,
|
||||
businessEmailValidator(value) {
|
||||
return CompanyEmailValidator.isCompanyEmail(value);
|
||||
},
|
||||
didCaptchaReset: false,
|
||||
isSignupInProgress: false,
|
||||
error: '',
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
return {
|
||||
credentials: {
|
||||
accountName: {
|
||||
required,
|
||||
minLength: minLength(2),
|
||||
},
|
||||
fullName: {
|
||||
required,
|
||||
minLength: minLength(2),
|
||||
},
|
||||
email: {
|
||||
required,
|
||||
email,
|
||||
businessEmailValidator(value) {
|
||||
return CompanyEmailValidator.isCompanyEmail(value);
|
||||
},
|
||||
},
|
||||
password: {
|
||||
required,
|
||||
isValidPassword,
|
||||
minLength: minLength(MIN_PASSWORD_LENGTH),
|
||||
},
|
||||
confirmPassword: {
|
||||
required,
|
||||
minLength: minLength(MIN_PASSWORD_LENGTH),
|
||||
sameAsPassword: sameAs(this.credentials.password),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ globalConfig: 'globalConfig/get' }),
|
||||
termsLink() {
|
||||
return this.$t('REGISTER.TERMS_ACCEPT')
|
||||
.replace('https://www.chatwoot.com/terms', this.globalConfig.termsURL)
|
||||
.replace(
|
||||
'https://www.chatwoot.com/privacy-policy',
|
||||
this.globalConfig.privacyURL
|
||||
);
|
||||
},
|
||||
hasAValidCaptcha() {
|
||||
if (this.globalConfig.hCaptchaSiteKey) {
|
||||
return !!this.credentials.hCaptchaClientResponse;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
confirmPasswordErrorText() {
|
||||
const { confirmPassword } = this.v$.credentials;
|
||||
if (!confirmPassword.$error) return '';
|
||||
if (confirmPassword.sameAsPassword.$invalid) {
|
||||
return this.$t('REGISTER.CONFIRM_PASSWORD.ERROR');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
allowedLoginMethods() {
|
||||
return window.chatwootConfig.allowedLoginMethods || ['email'];
|
||||
},
|
||||
showGoogleOAuth() {
|
||||
return (
|
||||
this.allowedLoginMethods.includes('google_oauth') &&
|
||||
Boolean(window.chatwootConfig.googleOAuthClientId)
|
||||
);
|
||||
},
|
||||
isFormValid() {
|
||||
return !this.v$.$invalid && this.hasAValidCaptcha;
|
||||
},
|
||||
passwordRequirements() {
|
||||
const password = this.credentials.password || '';
|
||||
return {
|
||||
length: password.length >= MIN_PASSWORD_LENGTH,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
number: /[0-9]/.test(password),
|
||||
special: SPECIAL_CHAR_REGEX.test(password),
|
||||
};
|
||||
},
|
||||
passwordRequirementItems() {
|
||||
const reqs = this.passwordRequirements;
|
||||
return [
|
||||
{
|
||||
id: 'length',
|
||||
met: reqs.length,
|
||||
label: this.$t('REGISTER.PASSWORD.REQUIREMENTS_LENGTH', {
|
||||
min: MIN_PASSWORD_LENGTH,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'uppercase',
|
||||
met: reqs.uppercase,
|
||||
label: this.$t('REGISTER.PASSWORD.REQUIREMENTS_UPPERCASE'),
|
||||
},
|
||||
{
|
||||
id: 'lowercase',
|
||||
met: reqs.lowercase,
|
||||
label: this.$t('REGISTER.PASSWORD.REQUIREMENTS_LOWERCASE'),
|
||||
},
|
||||
{
|
||||
id: 'number',
|
||||
met: reqs.number,
|
||||
label: this.$t('REGISTER.PASSWORD.REQUIREMENTS_NUMBER'),
|
||||
},
|
||||
{
|
||||
id: 'special',
|
||||
met: reqs.special,
|
||||
label: this.$t('REGISTER.PASSWORD.REQUIREMENTS_SPECIAL'),
|
||||
},
|
||||
];
|
||||
},
|
||||
passwordRequirementsMet() {
|
||||
return Object.values(this.passwordRequirements).every(Boolean);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async submit() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) {
|
||||
this.resetCaptcha();
|
||||
return;
|
||||
}
|
||||
this.isSignupInProgress = true;
|
||||
try {
|
||||
await register(this.credentials);
|
||||
window.location = DEFAULT_REDIRECT_URL;
|
||||
} catch (error) {
|
||||
let errorMessage =
|
||||
error?.message || this.$t('REGISTER.API.ERROR_MESSAGE');
|
||||
this.resetCaptcha();
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
this.isSignupInProgress = false;
|
||||
}
|
||||
},
|
||||
onRecaptchaVerified(token) {
|
||||
this.credentials.hCaptchaClientResponse = token;
|
||||
this.didCaptchaReset = false;
|
||||
this.v$.$touch();
|
||||
},
|
||||
resetCaptcha() {
|
||||
if (!this.globalConfig.hCaptchaSiteKey) return;
|
||||
this.$refs.hCaptcha.reset();
|
||||
this.credentials.hCaptchaClientResponse = '';
|
||||
this.didCaptchaReset = true;
|
||||
password: {
|
||||
required,
|
||||
isValidPassword,
|
||||
minLength: minLength(MIN_PASSWORD_LENGTH),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, { credentials });
|
||||
|
||||
const globalConfig = computed(() => store.getters['globalConfig/get']);
|
||||
|
||||
const termsLink = computed(() =>
|
||||
t('REGISTER.TERMS_ACCEPT')
|
||||
.replace('https://www.chatwoot.com/terms', globalConfig.value.termsURL)
|
||||
.replace(
|
||||
'https://www.chatwoot.com/privacy-policy',
|
||||
globalConfig.value.privacyURL
|
||||
)
|
||||
);
|
||||
|
||||
const allowedLoginMethods = computed(
|
||||
() => window.chatwootConfig.allowedLoginMethods || ['email']
|
||||
);
|
||||
|
||||
const showGoogleOAuth = computed(
|
||||
() =>
|
||||
allowedLoginMethods.value.includes('google_oauth') &&
|
||||
Boolean(window.chatwootConfig.googleOAuthClientId)
|
||||
);
|
||||
|
||||
const isFormValid = computed(() => !v$.value.$invalid);
|
||||
|
||||
const performRegistration = async () => {
|
||||
isSignupInProgress.value = true;
|
||||
try {
|
||||
await register(credentials);
|
||||
window.location = DEFAULT_REDIRECT_URL;
|
||||
} catch (error) {
|
||||
const errorMessage = error?.message || t('REGISTER.API.ERROR_MESSAGE');
|
||||
if (globalConfig.value.hCaptchaSiteKey) {
|
||||
hCaptcha.value.reset();
|
||||
credentials.hCaptchaClientResponse = '';
|
||||
}
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
isSignupInProgress.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (isSignupInProgress.value) return;
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
isSignupInProgress.value = true;
|
||||
if (globalConfig.value.hCaptchaSiteKey) {
|
||||
hCaptcha.value.execute();
|
||||
} else {
|
||||
performRegistration();
|
||||
}
|
||||
};
|
||||
|
||||
const onRecaptchaVerified = token => {
|
||||
credentials.hCaptchaClientResponse = token;
|
||||
performRegistration();
|
||||
};
|
||||
|
||||
const onCaptchaError = () => {
|
||||
isSignupInProgress.value = false;
|
||||
credentials.hCaptchaClientResponse = '';
|
||||
hCaptcha.value.reset();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 px-1 overflow-auto">
|
||||
<div class="flex-1">
|
||||
<form class="space-y-3" @submit.prevent="submit">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<FormInput
|
||||
v-model="credentials.fullName"
|
||||
name="full_name"
|
||||
class="flex-1"
|
||||
:class="{ error: v$.credentials.fullName.$error }"
|
||||
:label="$t('REGISTER.FULL_NAME.LABEL')"
|
||||
:placeholder="$t('REGISTER.FULL_NAME.PLACEHOLDER')"
|
||||
:has-error="v$.credentials.fullName.$error"
|
||||
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
|
||||
@blur="v$.credentials.fullName.$touch"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="credentials.accountName"
|
||||
name="account_name"
|
||||
class="flex-1"
|
||||
:class="{ error: v$.credentials.accountName.$error }"
|
||||
:label="$t('REGISTER.COMPANY_NAME.LABEL')"
|
||||
:placeholder="$t('REGISTER.COMPANY_NAME.PLACEHOLDER')"
|
||||
:has-error="v$.credentials.accountName.$error"
|
||||
:error-message="$t('REGISTER.COMPANY_NAME.ERROR')"
|
||||
@blur="v$.credentials.accountName.$touch"
|
||||
/>
|
||||
</div>
|
||||
<FormInput
|
||||
v-model="credentials.email"
|
||||
type="email"
|
||||
@@ -230,100 +127,62 @@ export default {
|
||||
:error-message="$t('REGISTER.EMAIL.ERROR')"
|
||||
@blur="v$.credentials.email.$touch"
|
||||
/>
|
||||
<FormInput
|
||||
v-model="credentials.password"
|
||||
type="password"
|
||||
name="password"
|
||||
:class="{ error: v$.credentials.password.$error }"
|
||||
:label="$t('LOGIN.PASSWORD.LABEL')"
|
||||
:placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')"
|
||||
:has-error="v$.credentials.password.$error"
|
||||
aria-describedby="password-requirements"
|
||||
@blur="v$.credentials.password.$touch"
|
||||
/>
|
||||
<div
|
||||
id="password-requirements"
|
||||
class="text-xs rounded-md px-4 py-3 outline outline-1 outline-n-weak bg-n-alpha-black2"
|
||||
>
|
||||
<ul role="list" class="grid grid-cols-2 gap-1">
|
||||
<li
|
||||
v-for="item in passwordRequirementItems"
|
||||
:key="item.id"
|
||||
class="inline-flex gap-1 items-start"
|
||||
>
|
||||
<Icon
|
||||
class="flex-none flex-shrink-0 w-3 mt-0.5"
|
||||
:icon="item.met ? 'i-lucide-circle-check-big' : 'i-lucide-circle'"
|
||||
:class="item.met ? 'text-n-teal-10' : 'text-n-slate-10'"
|
||||
/>
|
||||
|
||||
<span :class="item.met ? 'text-n-slate-11' : 'text-n-slate-10'">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<FormInput
|
||||
v-model="credentials.confirmPassword"
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
:class="{ error: v$.credentials.confirmPassword.$error }"
|
||||
:label="$t('REGISTER.CONFIRM_PASSWORD.LABEL')"
|
||||
:placeholder="$t('REGISTER.CONFIRM_PASSWORD.PLACEHOLDER')"
|
||||
:has-error="v$.credentials.confirmPassword.$error"
|
||||
:error-message="confirmPasswordErrorText"
|
||||
@blur="v$.credentials.confirmPassword.$touch"
|
||||
/>
|
||||
<div v-if="globalConfig.hCaptchaSiteKey" class="mb-3">
|
||||
<VueHcaptcha
|
||||
ref="hCaptcha"
|
||||
:class="{ error: !hasAValidCaptcha && didCaptchaReset }"
|
||||
:sitekey="globalConfig.hCaptchaSiteKey"
|
||||
@verify="onRecaptchaVerified"
|
||||
<div class="relative">
|
||||
<FormInput
|
||||
v-model="credentials.password"
|
||||
type="password"
|
||||
name="password"
|
||||
:class="{ error: v$.credentials.password.$error }"
|
||||
:label="$t('LOGIN.PASSWORD.LABEL')"
|
||||
:placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')"
|
||||
:has-error="v$.credentials.password.$error"
|
||||
@focus="isPasswordFocused = true"
|
||||
@blur="
|
||||
isPasswordFocused = false;
|
||||
v$.credentials.password.$touch();
|
||||
"
|
||||
/>
|
||||
<span
|
||||
v-if="!hasAValidCaptcha && didCaptchaReset"
|
||||
class="text-xs text-n-ruby-9"
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out origin-left"
|
||||
enter-from-class="opacity-0 scale-90 translate-x-1"
|
||||
enter-to-class="opacity-100 scale-100 translate-x-0"
|
||||
leave-active-class="transition duration-150 ease-in origin-left"
|
||||
leave-from-class="opacity-100 scale-100 translate-x-0"
|
||||
leave-to-class="opacity-0 scale-90 translate-x-1"
|
||||
>
|
||||
{{ $t('SET_NEW_PASSWORD.CAPTCHA.ERROR') }}
|
||||
</span>
|
||||
<PasswordRequirements
|
||||
v-if="isPasswordFocused"
|
||||
:password="credentials.password"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
<VueHcaptcha
|
||||
v-if="globalConfig.hCaptchaSiteKey"
|
||||
ref="hCaptcha"
|
||||
size="invisible"
|
||||
:sitekey="globalConfig.hCaptchaSiteKey"
|
||||
@verify="onRecaptchaVerified"
|
||||
@error="onCaptchaError"
|
||||
@expired="onCaptchaError"
|
||||
@challenge-expired="onCaptchaError"
|
||||
@closed="onCaptchaError"
|
||||
/>
|
||||
<NextButton
|
||||
lg
|
||||
type="submit"
|
||||
data-testid="submit_button"
|
||||
class="w-full"
|
||||
icon="i-lucide-chevron-right"
|
||||
trailing-icon
|
||||
class="w-full font-medium"
|
||||
:label="$t('REGISTER.SUBMIT')"
|
||||
:disabled="isSignupInProgress || !isFormValid"
|
||||
:is-loading="isSignupInProgress"
|
||||
/>
|
||||
</form>
|
||||
<div class="flex flex-col">
|
||||
<SimpleDivider
|
||||
v-if="showGoogleOAuth"
|
||||
:label="$t('COMMON.OR')"
|
||||
bg="bg-n-background"
|
||||
class="uppercase"
|
||||
/>
|
||||
<GoogleOAuthButton v-if="showGoogleOAuth">
|
||||
{{ $t('REGISTER.OAUTH.GOOGLE_SIGNUP') }}
|
||||
</GoogleOAuthButton>
|
||||
</div>
|
||||
<GoogleOAuthButton v-if="showGoogleOAuth" class="mt-3">
|
||||
{{ $t('REGISTER.OAUTH.GOOGLE_SIGNUP') }}
|
||||
</GoogleOAuthButton>
|
||||
<p
|
||||
class="text-sm mb-1 mt-5 text-n-slate-12 [&>a]:text-n-brand [&>a]:font-medium [&>a]:hover:brightness-110"
|
||||
class="text-sm mt-5 mb-0 text-n-slate-11 [&>a]:text-n-blue-10 [&>a]:font-medium [&>a]:hover:text-n-blue-11"
|
||||
v-html="termsLink"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.h-captcha--box {
|
||||
&::v-deep .error {
|
||||
iframe {
|
||||
@apply rounded-md border border-n-ruby-8 dark:border-n-ruby-8;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
password: { type: String, default: '' },
|
||||
});
|
||||
const MIN_PASSWORD_LENGTH = 6;
|
||||
const SPECIAL_CHAR_REGEX = /[!@#$%^&*()_+\-=[\]{}|'"/\\.,`<>:;?~]/;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const requirements = computed(() => {
|
||||
const password = props.password || '';
|
||||
return [
|
||||
{
|
||||
id: 'length',
|
||||
met: password.length >= MIN_PASSWORD_LENGTH,
|
||||
label: t('REGISTER.PASSWORD.REQUIREMENTS_LENGTH', {
|
||||
min: MIN_PASSWORD_LENGTH,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'uppercase',
|
||||
met: /[A-Z]/.test(password),
|
||||
label: t('REGISTER.PASSWORD.REQUIREMENTS_UPPERCASE'),
|
||||
},
|
||||
{
|
||||
id: 'lowercase',
|
||||
met: /[a-z]/.test(password),
|
||||
label: t('REGISTER.PASSWORD.REQUIREMENTS_LOWERCASE'),
|
||||
},
|
||||
{
|
||||
id: 'number',
|
||||
met: /[0-9]/.test(password),
|
||||
label: t('REGISTER.PASSWORD.REQUIREMENTS_NUMBER'),
|
||||
},
|
||||
{
|
||||
id: 'special',
|
||||
met: SPECIAL_CHAR_REGEX.test(password),
|
||||
label: t('REGISTER.PASSWORD.REQUIREMENTS_SPECIAL'),
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-0 z-50 w-64 text-xs rounded-lg px-4 py-3 bg-white dark:bg-n-solid-3 shadow-lg outline outline-1 outline-n-weak start-full ms-4"
|
||||
>
|
||||
<ul role="list" class="space-y-1.5">
|
||||
<li
|
||||
v-for="item in requirements"
|
||||
:key="item.id"
|
||||
class="inline-flex gap-1.5 items-start"
|
||||
>
|
||||
<Icon
|
||||
class="flex-none flex-shrink-0 w-3 mt-0.5"
|
||||
:icon="item.met ? 'i-lucide-circle-check-big' : 'i-lucide-circle'"
|
||||
:class="item.met ? 'text-n-teal-10' : 'text-n-slate-10'"
|
||||
/>
|
||||
<span :class="item.met ? 'text-n-slate-11' : 'text-n-slate-10'">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,66 +1,48 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, onBeforeMount } from 'vue';
|
||||
import TestimonialCard from './TestimonialCard.vue';
|
||||
import { getTestimonialContent } from '../../../../../api/testimonials';
|
||||
export default {
|
||||
components: { TestimonialCard },
|
||||
emits: ['resizeContainers'],
|
||||
data() {
|
||||
return { testimonials: [] };
|
||||
},
|
||||
beforeMount() {
|
||||
this.fetchTestimonials();
|
||||
},
|
||||
methods: {
|
||||
async fetchTestimonials() {
|
||||
try {
|
||||
const { data } = await getTestimonialContent();
|
||||
this.testimonials = data;
|
||||
} catch (error) {
|
||||
// Ignoring the error as the UI wouldn't break
|
||||
} finally {
|
||||
this.$emit('resizeContainers', !!this.testimonials.length);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
const emit = defineEmits(['resizeContainers']);
|
||||
|
||||
const testimonial = ref(null);
|
||||
|
||||
const fetchTestimonials = async () => {
|
||||
try {
|
||||
const { data } = await getTestimonialContent();
|
||||
if (data.length) {
|
||||
testimonial.value = data[Math.floor(Math.random() * data.length)];
|
||||
}
|
||||
} catch {
|
||||
// Ignoring the error as the UI wouldn't break
|
||||
} finally {
|
||||
emit('resizeContainers', !!testimonial.value);
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeMount(() => {
|
||||
fetchTestimonials();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-show="testimonials.length"
|
||||
class="relative flex-1 min-h-screen hidden overflow-hidden bg-n-blue-8 dark:bg-n-blue-5 xl:flex"
|
||||
class="relative flex-1 flex flex-col items-start justify-center bg-n-alpha-black2 dark:bg-n-solid-3 px-12 py-14 rounded-e-lg"
|
||||
>
|
||||
<img
|
||||
src="assets/images/auth/top-left.svg"
|
||||
class="absolute top-0 left-0 w-40 h-40"
|
||||
<TestimonialCard
|
||||
v-if="testimonial"
|
||||
:review-content="testimonial.authorReview"
|
||||
:author-image="testimonial.authorImage"
|
||||
:author-name="testimonial.authorName"
|
||||
:author-designation="testimonial.authorCompany"
|
||||
/>
|
||||
<img
|
||||
src="assets/images/auth/bottom-right.svg"
|
||||
class="absolute bottom-0 right-0 w-40 h-40"
|
||||
/>
|
||||
<img
|
||||
src="assets/images/auth/auth--bg.svg"
|
||||
class="h-[96%] left-[6%] top-[8%] w-[96%] absolute"
|
||||
/>
|
||||
<div
|
||||
class="z-50 flex flex-col items-center justify-center w-full h-full min-h-screen"
|
||||
>
|
||||
<div class="flex items-start justify-center p-6">
|
||||
<TestimonialCard
|
||||
v-for="(testimonial, index) in testimonials"
|
||||
:key="testimonial.id"
|
||||
:review-content="testimonial.authorReview"
|
||||
:author-image="testimonial.authorImage"
|
||||
:author-name="testimonial.authorName"
|
||||
:author-designation="testimonial.authorCompany"
|
||||
:class="!index ? 'mt-[20%] -mr-4 z-50' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute bottom-8 right-8 grid grid-cols-3 gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-n-gray-5" />
|
||||
<span class="w-2 h-2 rounded-full bg-n-gray-5" />
|
||||
<span class="w-2 h-2 rounded-full bg-n-gray-5" />
|
||||
<span class="w-2 h-2 rounded-full bg-n-gray-5" />
|
||||
<span class="w-2 h-2 rounded-full bg-n-gray-5" />
|
||||
<span class="w-2 h-2 rounded-full bg-n-gray-5" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.center--img {
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,40 +1,39 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
reviewContent: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
authorImage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
authorName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
authorDesignation: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
<script setup>
|
||||
defineProps({
|
||||
reviewContent: { type: String, default: '' },
|
||||
authorImage: { type: String, default: '' },
|
||||
authorName: { type: String, default: '' },
|
||||
authorDesignation: { type: String, default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col items-start justify-center p-6 w-80 bg-n-background rounded-lg drop-shadow-md"
|
||||
>
|
||||
<p class="text-sm text-n-slate-12 tracking-normal">
|
||||
<div class="flex flex-col items-start">
|
||||
<svg
|
||||
class="w-10 h-10 text-n-slate-7 mb-6"
|
||||
viewBox="0 0 40 40"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M10.7 28.3c-1.4-1.2-2.1-3-2.1-5.4 0-2.3.8-4.6 2.4-6.8 1.6-2.2 3.8-3.9 6.6-5.1l1.2 2.1c-2.2 1.2-3.7 2.4-4.6 3.6-.9 1.2-1.3 2.5-1.2 3.8.3-.1.7-.2 1.2-.2 1.3 0 2.4.4 3.3 1.3.9.9 1.3 2 1.3 3.3 0 1.4-.5 2.5-1.4 3.4-.9.9-2.1 1.4-3.5 1.4-1.5 0-2.7-.5-3.2-1.4zm15 0c-1.4-1.2-2.1-3-2.1-5.4 0-2.3.8-4.6 2.4-6.8 1.6-2.2 3.8-3.9 6.6-5.1l1.2 2.1c-2.2 1.2-3.7 2.4-4.6 3.6-.9 1.2-1.3 2.5-1.2 3.8.3-.1.7-.2 1.2-.2 1.3 0 2.4.4 3.3 1.3.9.9 1.3 2 1.3 3.3 0 1.4-.5 2.5-1.4 3.4-.9.9-2.1 1.4-3.5 1.4-1.5 0-2.7-.5-3.2-1.4z"
|
||||
/>
|
||||
</svg>
|
||||
<p
|
||||
class="text-lg text-n-slate-12 leading-relaxed whitespace-pre-line tracking-tight"
|
||||
>
|
||||
{{ reviewContent }}
|
||||
</p>
|
||||
<div class="flex items-center mt-4 text-n-slate-12">
|
||||
<div class="bg-white rounded-full p-1">
|
||||
<img :src="authorImage" class="h-8 w-8 rounded-full" />
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
<div class="text-sm font-medium">{{ authorName }}</div>
|
||||
<div class="text-xs">{{ authorDesignation }}</div>
|
||||
<div class="flex items-center mt-8">
|
||||
<img
|
||||
:src="authorImage"
|
||||
:alt="authorName"
|
||||
class="w-11 h-11 rounded-full object-cover"
|
||||
/>
|
||||
<div class="ml-3">
|
||||
<div class="text-base font-medium text-n-slate-12">
|
||||
{{ authorName }}
|
||||
</div>
|
||||
<div class="text-sm text-n-slate-10">{{ authorDesignation }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -167,6 +167,10 @@ class Conversation < ApplicationRecord
|
||||
agent_last_seen_at.present? ? messages.created_since(agent_last_seen_at) : messages
|
||||
end
|
||||
|
||||
def assignee_unread_messages
|
||||
assignee_last_seen_at.present? ? messages.created_since(assignee_last_seen_at) : messages
|
||||
end
|
||||
|
||||
def unread_incoming_messages
|
||||
unread_messages.where(account_id: account_id).incoming.last(10)
|
||||
end
|
||||
|
||||
@@ -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?
|
||||
|
||||
+1
-10
@@ -310,11 +310,10 @@ class Message < ApplicationRecord
|
||||
def execute_after_create_commit_callbacks
|
||||
# rails issue with order of active record callbacks being executed https://github.com/rails/rails/issues/20911
|
||||
reopen_conversation
|
||||
open_conversation_on_human_response
|
||||
set_conversation_activity
|
||||
dispatch_create_events
|
||||
send_reply
|
||||
execute_message_template_hooks if incoming?
|
||||
execute_message_template_hooks
|
||||
update_contact_activity
|
||||
end
|
||||
|
||||
@@ -390,14 +389,6 @@ class Message < ApplicationRecord
|
||||
reopen_resolved_conversation if conversation.resolved?
|
||||
end
|
||||
|
||||
def open_conversation_on_human_response
|
||||
return unless human_response?
|
||||
return unless conversation.pending?
|
||||
return if private?
|
||||
|
||||
conversation.open!
|
||||
end
|
||||
|
||||
def reopen_resolved_conversation
|
||||
# mark resolved bot conversation as pending to be reopened by bot processor service
|
||||
if conversation.inbox.active_bot?
|
||||
|
||||
@@ -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
|
||||
@@ -51,7 +51,6 @@ class Messages::SearchDataPresenter < SimpleDelegator
|
||||
|
||||
def additional_attributes_data
|
||||
{
|
||||
campaign_id: additional_attributes&.dig('campaign_id'),
|
||||
automation_rule_id: content_attributes&.dig('automation_rule_id')
|
||||
}
|
||||
end
|
||||
|
||||
@@ -136,7 +136,8 @@ class CsatTemplateManagementService
|
||||
template_exists: true,
|
||||
template_name: template_name,
|
||||
status: status_result[:template][:status],
|
||||
template_id: status_result[:template][:id]
|
||||
template_id: status_result[:template][:id],
|
||||
business_account_id: @inbox.channel.provider_config['business_account_id']
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -49,11 +49,6 @@ class MessageTemplates::HookExecutionService
|
||||
|
||||
# TODO: we should be able to reduce this logic once we have a toggle for email collect messages
|
||||
def should_send_email_collect?
|
||||
# Don't send email collect if a human agent has already replied
|
||||
# It breaks UX if an automated template shows up during a human conversation
|
||||
# The agent can manually collect email if needed
|
||||
return false if conversation.messages.outgoing.exists?(private: false, sender_type: 'User')
|
||||
|
||||
!contact_has_email? && inbox.web_widget? && !email_collect_was_sent?
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderers::BaseMarkdownRenderer
|
||||
def initialize
|
||||
super
|
||||
@list_item_number = 0
|
||||
end
|
||||
|
||||
def strong(_node)
|
||||
out('*', :children, '*')
|
||||
end
|
||||
@@ -15,13 +20,20 @@ class Messages::MarkdownRenderers::WhatsAppRenderer < Messages::MarkdownRenderer
|
||||
out(node.url)
|
||||
end
|
||||
|
||||
def list(_node)
|
||||
def list(node)
|
||||
@list_type = node.list_type
|
||||
@list_item_number = @list_type == :ordered_list ? node.list_start : 0
|
||||
out(:children)
|
||||
cr
|
||||
end
|
||||
|
||||
def list_item(_node)
|
||||
out('- ', :children)
|
||||
if @list_type == :ordered_list
|
||||
out("#{@list_item_number}. ", :children)
|
||||
@list_item_number += 1
|
||||
else
|
||||
out('- ', :children)
|
||||
end
|
||||
cr
|
||||
end
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ class Whatsapp::CsatTemplateService
|
||||
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||
DEFAULT_LANGUAGE = 'en'.freeze
|
||||
WHATSAPP_API_VERSION = 'v14.0'.freeze
|
||||
TEMPLATE_CATEGORY = 'MARKETING'.freeze
|
||||
TEMPLATE_CATEGORY = 'UTILITY'.freeze
|
||||
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
|
||||
|
||||
def initialize(whatsapp_channel)
|
||||
|
||||
@@ -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,9 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<% @portal.articles.where(status: :published).each do |article| %>
|
||||
<sitemap>
|
||||
<url>
|
||||
<loc><%= @help_center_url %><%= generate_article_link(@portal.slug, article.slug, false, false) %></loc>
|
||||
<lastmod><%= article.updated_at.strftime("%Y-%m-%d") %></lastmod>
|
||||
</sitemap>
|
||||
<lastmod><%= article.updated_at.to_date.iso8601 %></lastmod>
|
||||
</url>
|
||||
<% end %>
|
||||
</sitemapindex>
|
||||
</urlset>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.10.0'
|
||||
version: '4.10.1'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -234,3 +234,6 @@
|
||||
display_name: CSAT Review Notes
|
||||
enabled: false
|
||||
premium: true
|
||||
- name: captain_tasks
|
||||
display_name: Captain Tasks
|
||||
enabled: true
|
||||
|
||||
@@ -345,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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -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_14_201315) 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"
|
||||
|
||||
@@ -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
|
||||
@@ -4,9 +4,9 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
|
||||
end
|
||||
description 'Search conversations based on parameters'
|
||||
|
||||
param :status, type: :string, desc: 'Status of the conversation'
|
||||
param :status, type: :string, desc: 'Status of the conversation (open, resolved, pending, snoozed). Leave empty to search all statuses.'
|
||||
param :contact_id, type: :number, desc: 'Contact id'
|
||||
param :priority, type: :string, desc: 'Priority of conversation'
|
||||
param :priority, type: :string, desc: 'Priority of conversation (low, medium, high, urgent). Leave empty to search all priorities.'
|
||||
param :labels, type: :string, desc: 'Labels available'
|
||||
|
||||
def execute(status: nil, contact_id: nil, priority: nil, labels: nil)
|
||||
@@ -19,7 +19,7 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
|
||||
|
||||
<<~RESPONSE
|
||||
#{total_count > 100 ? "Found #{total_count} conversations (showing first 100)" : "Total number of conversations: #{total_count}"}
|
||||
#{conversations.map { |conversation| conversation.to_llm_text(include_contact_details: true) }.join("\n---\n")}
|
||||
#{conversations.map { |conversation| conversation.to_llm_text(include_contact_details: true, include_private_messages: true) }.join("\n---\n")}
|
||||
RESPONSE
|
||||
end
|
||||
|
||||
@@ -34,12 +34,20 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
|
||||
def get_conversations(status, contact_id, priority, labels)
|
||||
conversations = permissible_conversations
|
||||
conversations = conversations.where(contact_id: contact_id) if contact_id.present?
|
||||
conversations = conversations.where(status: status) if status.present?
|
||||
conversations = conversations.where(priority: priority) if priority.present?
|
||||
conversations = conversations.where(status: status) if valid_status?(status)
|
||||
conversations = conversations.where(priority: priority) if valid_priority?(priority)
|
||||
conversations = conversations.tagged_with(labels, any: true) if labels.present?
|
||||
conversations
|
||||
end
|
||||
|
||||
def valid_status?(status)
|
||||
status.present? && Conversation.statuses.key?(status)
|
||||
end
|
||||
|
||||
def valid_priority?(priority)
|
||||
priority.present? && Conversation.priorities.key?(priority)
|
||||
end
|
||||
|
||||
def permissible_conversations
|
||||
Conversations::PermissionFilterService.new(
|
||||
@assistant.account.conversations,
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
- audit_logs
|
||||
- response_bot
|
||||
- sla
|
||||
- captain_integration
|
||||
- custom_roles
|
||||
- captain_integration
|
||||
- csat_review_notes
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
module Enterprise::Captain::BaseTaskService
|
||||
def perform
|
||||
return { error: I18n.t('captain.copilot_limit'), error_code: 429 } unless responses_available?
|
||||
|
||||
unless captain_tasks_enabled?
|
||||
return { error: I18n.t('captain.upgrade') } if ChatwootApp.chatwoot_cloud?
|
||||
|
||||
return { error: I18n.t('captain.disabled') }
|
||||
end
|
||||
|
||||
result = super
|
||||
increment_usage if successful_result?(result)
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def responses_available?
|
||||
return true unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
account.usage_limits[:captain][:responses][:current_available].positive?
|
||||
end
|
||||
|
||||
def successful_result?(result)
|
||||
result.is_a?(Hash) && result[:message].present? && !result[:error]
|
||||
end
|
||||
|
||||
def increment_usage
|
||||
Rails.logger.info("[CAPTAIN][#{self.class.name}] Incrementing response usage for account #{account.id}")
|
||||
account.increment_response_usage
|
||||
end
|
||||
end
|
||||
@@ -1,82 +0,0 @@
|
||||
module Enterprise::Integrations::OpenaiProcessorService
|
||||
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion label_suggestion fix_spelling_grammar shorten expand
|
||||
make_friendly make_formal simplify].freeze
|
||||
CACHEABLE_EVENTS = %w[label_suggestion].freeze
|
||||
|
||||
def label_suggestion_message
|
||||
payload = label_suggestion_body
|
||||
return nil if payload.blank?
|
||||
|
||||
response = make_api_call(label_suggestion_body)
|
||||
|
||||
return response if response[:error].present?
|
||||
|
||||
# LLMs are not deterministic, so this is bandaid solution
|
||||
# To what you ask? Sometimes, the response includes
|
||||
# "Labels:" in it's response in some format. This is a hacky way to remove it
|
||||
# TODO: Fix with with a better prompt
|
||||
{ message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def labels_with_messages
|
||||
return nil unless valid_conversation?(conversation)
|
||||
|
||||
labels = hook.account.labels.pluck(:title).join(', ')
|
||||
character_count = labels.length
|
||||
|
||||
messages = init_messages_body(false)
|
||||
add_messages_until_token_limit(conversation, messages, false, character_count)
|
||||
|
||||
return nil if messages.blank? || labels.blank?
|
||||
|
||||
"Messages:\n#{messages}\nLabels:\n#{labels}"
|
||||
end
|
||||
|
||||
def valid_conversation?(conversation)
|
||||
return false if conversation.nil?
|
||||
return false if conversation.messages.incoming.count < 3
|
||||
|
||||
# Think Mark think, at this point the conversation is beyond saving
|
||||
return false if conversation.messages.count > 100
|
||||
|
||||
# if there are more than 20 messages, only trigger this if the last message is from the client
|
||||
return false if conversation.messages.count > 20 && !conversation.messages.last.incoming?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def summarize_body
|
||||
{
|
||||
model: self.class::GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system',
|
||||
content: prompt_from_file('summary', enterprise: true) },
|
||||
{ role: 'user', content: conversation_messages }
|
||||
]
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def label_suggestion_body
|
||||
return unless label_suggestions_enabled?
|
||||
|
||||
content = labels_with_messages
|
||||
return value_from_cache if content.blank?
|
||||
|
||||
{
|
||||
model: self.class::GPT_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: prompt_from_file('label_suggestion', enterprise: true)
|
||||
},
|
||||
{ role: 'user', content: content }
|
||||
]
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def label_suggestions_enabled?
|
||||
hook.settings['label_suggestion'].present?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,181 @@
|
||||
class Captain::BaseTaskService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
# gpt-4o-mini supports 128,000 tokens
|
||||
# 1 token is approx 4 characters
|
||||
# sticking with 120000 to be safe
|
||||
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
|
||||
TOKEN_LIMIT = 400_000
|
||||
GPT_MODEL = Llm::Config::DEFAULT_MODEL
|
||||
|
||||
# Prepend enterprise module to subclasses when they're defined.
|
||||
# This ensures the enterprise perform wrapper is applied even when
|
||||
# subclasses define their own perform method, since prepend puts
|
||||
# the module before the class in the ancestor chain.
|
||||
def self.inherited(subclass)
|
||||
super
|
||||
subclass.prepend_mod_with('Captain::BaseTaskService')
|
||||
end
|
||||
|
||||
pattr_initialize [:account!, { conversation_display_id: nil }]
|
||||
|
||||
private
|
||||
|
||||
def event_name
|
||||
raise NotImplementedError, "#{self.class} must implement #event_name"
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= account.conversations.find_by(display_id: conversation_display_id)
|
||||
end
|
||||
|
||||
def api_base
|
||||
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
|
||||
endpoint = endpoint.chomp('/')
|
||||
"#{endpoint}/v1"
|
||||
end
|
||||
|
||||
def make_api_call(model:, messages:)
|
||||
# Community edition prerequisite checks
|
||||
# Enterprise module handles these with more specific error messages (cloud vs self-hosted)
|
||||
return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
|
||||
return { error: I18n.t('captain.api_key_missing'), error_code: 401 } unless api_key_configured?
|
||||
|
||||
instrumentation_params = build_instrumentation_params(model, messages)
|
||||
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
execute_ruby_llm_request(model: model, messages: messages)
|
||||
end
|
||||
|
||||
# Build follow-up context for client-side refinement, when applicable
|
||||
if build_follow_up_context? && response[:message].present?
|
||||
response.merge(follow_up_context: build_follow_up_context(messages, response))
|
||||
else
|
||||
response
|
||||
end
|
||||
end
|
||||
|
||||
def execute_ruby_llm_request(model:, messages:)
|
||||
Llm::Config.with_api_key(api_key, api_base: api_base) do |context|
|
||||
chat = context.chat(model: model)
|
||||
system_msg = messages.find { |m| m[:role] == 'system' }
|
||||
chat.with_instructions(system_msg[:content]) if system_msg
|
||||
|
||||
conversation_messages = messages.reject { |m| m[:role] == 'system' }
|
||||
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
|
||||
|
||||
add_messages_if_needed(chat, conversation_messages)
|
||||
response = chat.ask(conversation_messages.last[:content])
|
||||
build_ruby_llm_response(response, messages)
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
{ error: e.message, request_messages: messages }
|
||||
end
|
||||
|
||||
def add_messages_if_needed(chat, conversation_messages)
|
||||
return if conversation_messages.length == 1
|
||||
|
||||
conversation_messages[0...-1].each do |msg|
|
||||
chat.add_message(role: msg[:role].to_sym, content: msg[:content])
|
||||
end
|
||||
end
|
||||
|
||||
def build_ruby_llm_response(response, messages)
|
||||
{
|
||||
message: response.content,
|
||||
usage: {
|
||||
'prompt_tokens' => response.input_tokens,
|
||||
'completion_tokens' => response.output_tokens,
|
||||
'total_tokens' => (response.input_tokens || 0) + (response.output_tokens || 0)
|
||||
},
|
||||
request_messages: messages
|
||||
}
|
||||
end
|
||||
|
||||
def build_instrumentation_params(model, messages)
|
||||
{
|
||||
span_name: "llm.#{event_name}",
|
||||
account_id: account.id,
|
||||
conversation_id: conversation&.display_id,
|
||||
feature_name: event_name,
|
||||
model: model,
|
||||
messages: messages,
|
||||
temperature: nil,
|
||||
metadata: instrumentation_metadata
|
||||
}
|
||||
end
|
||||
|
||||
def instrumentation_metadata
|
||||
{
|
||||
channel_type: conversation&.inbox&.channel_type
|
||||
}.compact
|
||||
end
|
||||
|
||||
def conversation_messages(start_from: 0)
|
||||
messages = []
|
||||
character_count = start_from
|
||||
|
||||
conversation.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.reorder('id desc')
|
||||
.each do |message|
|
||||
content = message.content_for_llm
|
||||
break unless content.present? && character_count + content.length <= TOKEN_LIMIT
|
||||
|
||||
messages.prepend({ role: (message.incoming? ? 'user' : 'assistant'), content: content })
|
||||
character_count += content.length
|
||||
end
|
||||
|
||||
messages
|
||||
end
|
||||
|
||||
def captain_tasks_enabled?
|
||||
account.feature_enabled?('captain_tasks')
|
||||
end
|
||||
|
||||
def api_key_configured?
|
||||
api_key.present?
|
||||
end
|
||||
|
||||
def api_key
|
||||
@api_key ||= openai_hook&.settings&.dig('api_key') || system_api_key
|
||||
end
|
||||
|
||||
def openai_hook
|
||||
@openai_hook ||= account.hooks.find_by(app_id: 'openai', status: 'enabled')
|
||||
end
|
||||
|
||||
def system_api_key
|
||||
@system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
|
||||
end
|
||||
|
||||
def prompt_from_file(file_name)
|
||||
Rails.root.join('lib/integrations/openai/openai_prompts', "#{file_name}.liquid").read
|
||||
end
|
||||
|
||||
# Follow-up context for client-side refinement
|
||||
def build_follow_up_context?
|
||||
# FollowUpService should return its own updated context
|
||||
!is_a?(Captain::FollowUpService)
|
||||
end
|
||||
|
||||
def build_follow_up_context(messages, response)
|
||||
{
|
||||
event_name: event_name,
|
||||
original_context: extract_original_context(messages),
|
||||
last_response: response[:message],
|
||||
conversation_history: [],
|
||||
channel_type: conversation&.inbox&.channel_type
|
||||
}
|
||||
end
|
||||
|
||||
def extract_original_context(messages)
|
||||
# Get the most recent user message for follow-up context
|
||||
user_msg = messages.reverse.find { |m| m[:role] == 'user' }
|
||||
user_msg ? user_msg[:content] : nil
|
||||
end
|
||||
end
|
||||
|
||||
Captain::BaseTaskService.prepend_mod_with('Captain::BaseTaskService')
|
||||
@@ -0,0 +1,106 @@
|
||||
class Captain::FollowUpService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :follow_up_context!, :user_message!, { conversation_display_id: nil }]
|
||||
|
||||
ALLOWED_EVENT_NAMES = %w[
|
||||
professional
|
||||
casual
|
||||
friendly
|
||||
confident
|
||||
straightforward
|
||||
fix_spelling_grammar
|
||||
improve
|
||||
summarize
|
||||
reply_suggestion
|
||||
label_suggestion
|
||||
].freeze
|
||||
|
||||
def perform
|
||||
return { error: 'Follow-up context missing', error_code: 400 } unless valid_follow_up_context?
|
||||
|
||||
# Build context-aware system prompt
|
||||
system_prompt = build_follow_up_system_prompt(follow_up_context)
|
||||
|
||||
# Build full message array (convert history from string keys to symbol keys)
|
||||
history = follow_up_context['conversation_history'].to_a.map do |msg|
|
||||
{ role: msg['role'], content: msg['content'] }
|
||||
end
|
||||
|
||||
messages = [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: follow_up_context['original_context'] },
|
||||
{ role: 'assistant', content: follow_up_context['last_response'] },
|
||||
*history,
|
||||
{ role: 'user', content: user_message }
|
||||
]
|
||||
|
||||
response = make_api_call(model: GPT_MODEL, messages: messages)
|
||||
return response if response[:error]
|
||||
|
||||
response.merge(follow_up_context: update_follow_up_context(user_message, response[:message]))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_follow_up_system_prompt(session_data)
|
||||
action_context = describe_previous_action(session_data['event_name'])
|
||||
|
||||
<<~PROMPT
|
||||
You just performed a #{action_context} action for a customer support agent.
|
||||
Your job now is to help them refine the result based on their feedback.
|
||||
Be concise and focused on their specific request.
|
||||
Output only the reply, no preamble, tags, or explanation.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def describe_previous_action(event_name)
|
||||
case event_name
|
||||
when 'professional', 'casual', 'friendly', 'confident', 'straightforward'
|
||||
"tone rewrite (#{event_name})"
|
||||
when 'fix_spelling_grammar'
|
||||
'spelling and grammar correction'
|
||||
when 'improve'
|
||||
'message improvement'
|
||||
when 'summarize'
|
||||
'conversation summary'
|
||||
when 'reply_suggestion'
|
||||
'reply suggestion'
|
||||
when 'label_suggestion'
|
||||
'label suggestion'
|
||||
else
|
||||
event_name
|
||||
end
|
||||
end
|
||||
|
||||
def valid_follow_up_context?
|
||||
return false unless follow_up_context.is_a?(Hash)
|
||||
return false unless ALLOWED_EVENT_NAMES.include?(follow_up_context['event_name'])
|
||||
|
||||
required_keys = %w[event_name original_context last_response]
|
||||
required_keys.all? { |key| follow_up_context[key].present? }
|
||||
end
|
||||
|
||||
def update_follow_up_context(user_msg, assistant_msg)
|
||||
updated_history = follow_up_context['conversation_history'].to_a + [
|
||||
{ 'role' => 'user', 'content' => user_msg },
|
||||
{ 'role' => 'assistant', 'content' => assistant_msg }
|
||||
]
|
||||
|
||||
{
|
||||
'event_name' => follow_up_context['event_name'],
|
||||
'original_context' => follow_up_context['original_context'],
|
||||
'last_response' => assistant_msg,
|
||||
'conversation_history' => updated_history,
|
||||
'channel_type' => follow_up_context['channel_type']
|
||||
}
|
||||
end
|
||||
|
||||
def instrumentation_metadata
|
||||
{
|
||||
channel_type: conversation&.inbox&.channel_type || follow_up_context['channel_type']
|
||||
}.compact
|
||||
end
|
||||
|
||||
def event_name
|
||||
'follow_up'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,93 @@
|
||||
class Captain::LabelSuggestionService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :conversation_display_id!]
|
||||
|
||||
def perform
|
||||
# Check cache first
|
||||
cached_response = read_from_cache
|
||||
return cached_response if cached_response.present?
|
||||
|
||||
# Build content
|
||||
content = labels_with_messages
|
||||
return nil if content.blank?
|
||||
|
||||
# Make API call
|
||||
response = make_api_call(
|
||||
model: GPT_MODEL, # TODO: Use separate model for label suggestion
|
||||
messages: [
|
||||
{ role: 'system', content: prompt_from_file('label_suggestion') },
|
||||
{ role: 'user', content: content }
|
||||
]
|
||||
)
|
||||
return response if response[:error].present?
|
||||
|
||||
# Clean up response
|
||||
result = { message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' }
|
||||
|
||||
# Cache successful result
|
||||
write_to_cache(result)
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cache_key
|
||||
return nil unless conversation
|
||||
|
||||
format(
|
||||
::Redis::Alfred::OPENAI_CONVERSATION_KEY,
|
||||
event_name: 'label_suggestion',
|
||||
conversation_id: conversation.id,
|
||||
updated_at: conversation.last_activity_at.to_i
|
||||
)
|
||||
end
|
||||
|
||||
def read_from_cache
|
||||
return nil unless cache_key
|
||||
|
||||
cached = Redis::Alfred.get(cache_key)
|
||||
JSON.parse(cached, symbolize_names: true) if cached.present?
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
def write_to_cache(response)
|
||||
Redis::Alfred.setex(cache_key, response.to_json) if cache_key
|
||||
end
|
||||
|
||||
def labels_with_messages
|
||||
return nil unless valid_conversation?(conversation)
|
||||
|
||||
labels = account.labels.pluck(:title).join(', ')
|
||||
messages = format_messages_as_string(start_from: labels.length)
|
||||
|
||||
return nil if messages.blank? || labels.blank?
|
||||
|
||||
"Messages:\n#{messages}\nLabels:\n#{labels}"
|
||||
end
|
||||
|
||||
def format_messages_as_string(start_from: 0)
|
||||
messages = conversation_messages(start_from: start_from)
|
||||
messages.map do |msg|
|
||||
sender_type = msg[:role] == 'user' ? 'Customer' : 'Agent'
|
||||
"#{sender_type}: #{msg[:content]}\n"
|
||||
end.join
|
||||
end
|
||||
|
||||
def valid_conversation?(conversation)
|
||||
return false if conversation.nil?
|
||||
return false if conversation.messages.incoming.count < 3
|
||||
return false if conversation.messages.count > 100
|
||||
return false if conversation.messages.count > 20 && !conversation.messages.last.incoming?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def event_name
|
||||
'label_suggestion'
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
class Captain::ReplySuggestionService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :conversation_display_id!, :user!]
|
||||
|
||||
def perform
|
||||
make_api_call(
|
||||
model: GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: formatted_conversation }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def system_prompt
|
||||
template = prompt_from_file('reply')
|
||||
render_liquid_template(template, prompt_variables)
|
||||
end
|
||||
|
||||
def prompt_variables
|
||||
{
|
||||
'channel_type' => conversation.inbox.channel_type,
|
||||
'agent_name' => user.name,
|
||||
'agent_signature' => user.message_signature.presence
|
||||
}
|
||||
end
|
||||
|
||||
def render_liquid_template(template_content, variables = {})
|
||||
Liquid::Template.parse(template_content).render(variables)
|
||||
end
|
||||
|
||||
def formatted_conversation
|
||||
LlmFormatter::ConversationLlmFormatter.new(conversation).format(token_limit: TOKEN_LIMIT)
|
||||
end
|
||||
|
||||
def event_name
|
||||
'reply_suggestion'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
class Captain::RewriteService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :content!, :operation!, { conversation_display_id: nil }]
|
||||
|
||||
TONE_OPERATIONS = %i[casual professional friendly confident straightforward].freeze
|
||||
ALLOWED_OPERATIONS = (%i[fix_spelling_grammar improve] + TONE_OPERATIONS).freeze
|
||||
|
||||
def perform
|
||||
operation_sym = operation.to_sym
|
||||
raise ArgumentError, "Invalid operation: #{operation}" unless ALLOWED_OPERATIONS.include?(operation_sym)
|
||||
|
||||
send(operation_sym)
|
||||
end
|
||||
|
||||
TONE_OPERATIONS.each do |tone|
|
||||
define_method(tone) do
|
||||
call_llm_with_prompt(tone_rewrite_prompt(tone.to_s))
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fix_spelling_grammar
|
||||
call_llm_with_prompt(prompt_from_file('fix_spelling_grammar'))
|
||||
end
|
||||
|
||||
def improve
|
||||
template = prompt_from_file('improve')
|
||||
|
||||
system_prompt = render_liquid_template(template, {
|
||||
'conversation_context' => conversation.to_llm_text(include_contact_details: true),
|
||||
'draft_message' => content
|
||||
})
|
||||
|
||||
call_llm_with_prompt(system_prompt, content)
|
||||
end
|
||||
|
||||
def call_llm_with_prompt(system_content, user_content = content)
|
||||
make_api_call(
|
||||
model: GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: system_content },
|
||||
{ role: 'user', content: user_content }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
def render_liquid_template(template_content, variables = {})
|
||||
Liquid::Template.parse(template_content).render(variables)
|
||||
end
|
||||
|
||||
def tone_rewrite_prompt(tone)
|
||||
template = prompt_from_file('tone_rewrite')
|
||||
render_liquid_template(template, 'tone' => tone)
|
||||
end
|
||||
|
||||
def event_name
|
||||
operation
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
class Captain::SummaryService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :conversation_display_id!]
|
||||
|
||||
def perform
|
||||
make_api_call(
|
||||
model: GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt_from_file('summary') },
|
||||
{ role: 'user', content: conversation.to_llm_text(include_contact_details: false) }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def event_name
|
||||
'summarize'
|
||||
end
|
||||
end
|
||||
@@ -7,7 +7,8 @@ class Integrations::LlmBaseService
|
||||
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
|
||||
TOKEN_LIMIT = 400_000
|
||||
GPT_MODEL = Llm::Config::DEFAULT_MODEL
|
||||
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze
|
||||
ALLOWED_EVENT_NAMES = %w[summarize reply_suggestion fix_spelling_grammar casual professional friendly confident
|
||||
straightforward improve].freeze
|
||||
CACHEABLE_EVENTS = %w[].freeze
|
||||
|
||||
pattr_initialize [:hook!, :event!]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
You are an AI writing assistant integrated into Chatwoot, an omnichannel customer support platform. Your task is to fix grammar and spelling in a customer support message while preserving the original meaning, intent, and tone.
|
||||
|
||||
You will receive a message and must return a corrected version with only grammar, spelling, and punctuation fixes applied.
|
||||
|
||||
Important guidelines:
|
||||
- Preserve the original meaning, intent, and tone exactly
|
||||
- Do not rephrase, rewrite, or change wording beyond grammar, spelling, and punctuation
|
||||
- Do not add or remove any information
|
||||
- Do not simplify, shorten, or expand the message
|
||||
- Ensure the output remains appropriate for customer support
|
||||
|
||||
Super Important:
|
||||
- If the message has some markdown formatting, keep the formatting as it is.
|
||||
- Block quotes (lines starting with >) contain quoted text from the customer's previous message. Preserve this quoted text exactly as written (do not modify the customer's words inside the block quote), but DO improve the agent's reply that follows the block quote.
|
||||
- Ensure the output is in the user's original language
|
||||
- If the message contains a signature block (text after a `--` line), preserve the signature exactly as written without any modification. Do not add a signature if one is not already present.
|
||||
|
||||
Output only the corrected message, with no preamble, tags, or explanation.
|
||||
@@ -0,0 +1,44 @@
|
||||
You are a writing assistant for customer support agents. Your task is to improve a draft message by enhancing its language, clarity, and tone—not by adding new content.
|
||||
|
||||
<conversation_context>
|
||||
{{ conversation_context }}
|
||||
</conversation_context>
|
||||
|
||||
<draft_message>
|
||||
{{ draft_message }}
|
||||
</draft_message>
|
||||
|
||||
## Your Task
|
||||
|
||||
Rewrite the draft to be clearer, warmer, and more professional while preserving the agent's intent.
|
||||
|
||||
## What "Improve" Means
|
||||
|
||||
Improve the **quality** of the message, not the **quantity** of information:
|
||||
|
||||
| DO | DON'T |
|
||||
|-----|--------|
|
||||
| Fix grammar, spelling, punctuation | Add new information or steps |
|
||||
| Improve sentence structure and flow | Expand scope beyond the draft |
|
||||
| Make tone warmer and more professional | Add offers ("I can also...", "Would you like...") |
|
||||
| Use contact's name naturally | Invent technical details, links, or examples |
|
||||
| Make vague phrases more natural | Turn a brief answer into a long one |
|
||||
|
||||
## Using the Context
|
||||
|
||||
Use the conversation context to:
|
||||
- Understand what's being discussed (so improvements make sense)
|
||||
- Gauge appropriate tone (formal/casual, frustrated customer, etc.)
|
||||
- Personalize with the contact's name when natural
|
||||
|
||||
Do NOT use the context to fill in gaps or add information the agent didn't include.
|
||||
|
||||
## Output Rules
|
||||
|
||||
- Keep the improved message at a similar length to the draft (brief stays brief)
|
||||
- Preserve any markdown formatting
|
||||
- Block quotes (lines starting with `>`) contain quoted customer text—keep this unchanged, only improve the agent's reply
|
||||
- If the message contains a signature block (text after a `--` line), preserve the signature exactly as written without any modification. Do not add a signature if one is not already present.
|
||||
- Output in the same language as the draft
|
||||
- Output only the improved message, no commentary
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a customer and the support agent, along with a list of potential labels. Your task is to analyze the conversation and select the two labels from the given list that most accurately represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain the labels you\'ve selected,in their original casing, and nothing else.
|
||||
Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a customer and the support agent, along with a list of potential labels. Your task is to analyze the conversation and select the two labels from the given list that most accurately represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain the labels you've selected,in their original casing, and nothing else.
|
||||
@@ -0,0 +1,35 @@
|
||||
You are helping a customer support agent draft their next reply. The agent will send this message directly to the customer.
|
||||
|
||||
You will receive a conversation with messages labeled by sender:
|
||||
- "User:" = customer messages
|
||||
- "Support Agent:" = human agent messages
|
||||
- "Bot:" = automated bot messages
|
||||
|
||||
{% if channel_type == 'Channel::Email' %}
|
||||
This is an EMAIL conversation. Write a professional email reply that:
|
||||
- Uses appropriate email formatting (greeting, body, sign-off)
|
||||
- Is detailed and thorough where needed
|
||||
- Maintains a professional tone
|
||||
{% if agent_signature %}
|
||||
- End with the agent's signature exactly as provided below:
|
||||
|
||||
{{ agent_signature }}
|
||||
{% else %}
|
||||
- End with a professional sign-off using the agent's name: {{ agent_name }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
This is a CHAT conversation. Write a brief, conversational reply that:
|
||||
- Is short and easy to read
|
||||
- Gets to the point quickly
|
||||
- Does not include formal greetings or sign-offs
|
||||
{% endif %}
|
||||
|
||||
General guidelines:
|
||||
- Address the customer's most recent message directly
|
||||
- If a support agent has spoken before, match their writing style
|
||||
- If only bot messages exist, write a natural first message
|
||||
- Move the conversation forward
|
||||
- Do not invent product details, policies, or links that weren't mentioned
|
||||
- Reply in the customer's language
|
||||
|
||||
Output only the reply.
|
||||
@@ -1 +0,0 @@
|
||||
Please suggest a reply to the following conversation between support agents and customer. Don't expose that you are an AI model, respond "Couldn't generate the reply" in cases where you can't answer. Reply in the user\'s language.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user