Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
352854f409 | ||
|
|
04b2901e1f | ||
|
|
885b041a83 | ||
|
|
ad2329c237 | ||
|
|
747d451387 | ||
|
|
7887265fca | ||
|
|
8eb6fd1bff | ||
|
|
75f75ce786 | ||
|
|
964d2f8544 | ||
|
|
1f5fdd7199 | ||
|
|
9e97cc0cdd | ||
|
|
70d09fcc66 | ||
|
|
7386b2c983 | ||
|
|
cc5ec833dc | ||
|
|
e946fc89dd | ||
|
|
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]
|
||||
|
||||
@@ -92,8 +92,11 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def settings_params
|
||||
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
|
||||
conversation_required_attributes: [])
|
||||
params.permit(*permitted_settings_attributes)
|
||||
end
|
||||
|
||||
def permitted_settings_attributes
|
||||
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
@@ -112,3 +115,5 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::AccountsController.prepend_mod_with('Api::V1::AccountsSettings')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between items-center px-4 py-4 w-full">
|
||||
<div
|
||||
class="flex justify-center items-center py-6 w-full custom-dashed-border"
|
||||
>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.NO_ATTRIBUTES') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { ATTRIBUTE_TYPES } from './constants';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['delete']);
|
||||
|
||||
const iconByType = {
|
||||
[ATTRIBUTE_TYPES.TEXT]: 'i-lucide-align-justify',
|
||||
[ATTRIBUTE_TYPES.CHECKBOX]: 'i-lucide-circle-check-big',
|
||||
[ATTRIBUTE_TYPES.LIST]: 'i-lucide-list',
|
||||
[ATTRIBUTE_TYPES.DATE]: 'i-lucide-calendar',
|
||||
[ATTRIBUTE_TYPES.LINK]: 'i-lucide-link',
|
||||
[ATTRIBUTE_TYPES.NUMBER]: 'i-lucide-hash',
|
||||
};
|
||||
|
||||
const attributeIcon = computed(() => {
|
||||
const typeKey = props.attribute.type?.toLowerCase();
|
||||
return iconByType[typeKey] || 'i-lucide-align-justify';
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.attribute);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between items-center px-4 py-3 w-full">
|
||||
<div class="flex gap-3 items-center">
|
||||
<h5 class="text-sm font-medium text-n-slate-12 line-clamp-1">
|
||||
{{ attribute.label }}
|
||||
</h5>
|
||||
<div class="w-px h-2.5 bg-n-slate-5" />
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
|
||||
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
|
||||
</div>
|
||||
<div class="w-px h-2.5 bg-n-slate-5" />
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
|
||||
<span class="text-sm text-n-slate-11">{{ attribute.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Button icon="i-lucide-trash" sm slate ghost @click.stop="handleDelete" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import ConversationRequiredAttributeItem from 'dashboard/components-next/ConversationWorkflow/ConversationRequiredAttributeItem.vue';
|
||||
import ConversationRequiredEmpty from 'dashboard/components-next/Conversation/ConversationRequiredEmpty.vue';
|
||||
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
isEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { currentAccount, accountId, isOnChatwootCloud, updateAccount } =
|
||||
useAccount();
|
||||
const [showDropdown, toggleDropdown] = useToggle(false);
|
||||
const [isSaving, toggleSaving] = useToggle(false);
|
||||
const conversationAttributes = useMapGetter(
|
||||
'attributes/getConversationAttributes'
|
||||
);
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
|
||||
const showPaywall = computed(() => !props.isEnabled && isOnChatwootCloud.value);
|
||||
const i18nKey = computed(() =>
|
||||
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
|
||||
);
|
||||
|
||||
const goToBillingSettings = () => {
|
||||
router.push({
|
||||
name: 'billing_settings_index',
|
||||
params: { accountId: accountId.value },
|
||||
});
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
|
||||
const selectedAttributeKeys = computed(
|
||||
() => currentAccount.value?.settings?.conversation_required_attributes || []
|
||||
);
|
||||
|
||||
const allAttributeOptions = computed(() =>
|
||||
(conversationAttributes.value || []).map(attribute => ({
|
||||
...attribute,
|
||||
action: 'add',
|
||||
value: attribute.attributeKey,
|
||||
label: attribute.attributeDisplayName,
|
||||
type: attribute.attributeDisplayType,
|
||||
}))
|
||||
);
|
||||
|
||||
const attributeOptions = computed(() => {
|
||||
const selectedKeysSet = new Set(selectedAttributeKeys.value);
|
||||
return allAttributeOptions.value.filter(
|
||||
attribute => !selectedKeysSet.has(attribute.value)
|
||||
);
|
||||
});
|
||||
|
||||
const conversationRequiredAttributes = computed(() => {
|
||||
const attributeMap = new Map(
|
||||
allAttributeOptions.value.map(attr => [attr.value, attr])
|
||||
);
|
||||
return selectedAttributeKeys.value
|
||||
.map(key => attributeMap.get(key))
|
||||
.filter(Boolean);
|
||||
});
|
||||
|
||||
const handleAddAttributesClick = event => {
|
||||
event.stopPropagation();
|
||||
toggleDropdown();
|
||||
};
|
||||
|
||||
const saveRequiredAttributes = async keys => {
|
||||
try {
|
||||
toggleSaving(true);
|
||||
await updateAccount(
|
||||
{ conversation_required_attributes: keys },
|
||||
{ silent: true }
|
||||
);
|
||||
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.ERROR'));
|
||||
} finally {
|
||||
toggleSaving(false);
|
||||
toggleDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAttributeAction = ({ value }) => {
|
||||
if (!value || isSaving.value) return;
|
||||
const updatedKeys = Array.from(
|
||||
new Set([...selectedAttributeKeys.value, value])
|
||||
);
|
||||
saveRequiredAttributes(updatedKeys);
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
const handleDelete = attribute => {
|
||||
if (isSaving.value) return;
|
||||
const updatedKeys = selectedAttributeKeys.value.filter(
|
||||
key => key !== attribute.value
|
||||
);
|
||||
saveRequiredAttributes(updatedKeys);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isEnabled || showPaywall"
|
||||
class="flex flex-col w-full outline-1 outline outline-n-container rounded-xl bg-n-solid-2 divide-y divide-n-weak"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div class="flex flex-col gap-2 items-start px-5 py-4">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
|
||||
</h3>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="isEnabled" v-on-clickaway="closeDropdown" class="relative">
|
||||
<Button
|
||||
icon="i-lucide-circle-plus"
|
||||
:label="$t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.TITLE')"
|
||||
:is-loading="isSaving"
|
||||
:disabled="isSaving || attributeOptions.length === 0"
|
||||
@click="handleAddAttributesClick"
|
||||
/>
|
||||
<DropdownMenu
|
||||
v-if="showDropdown"
|
||||
:menu-items="attributeOptions"
|
||||
show-search
|
||||
:search-placeholder="
|
||||
$t(
|
||||
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="top-full mt-1 w-52 ltr:right-0 rtl:left-0"
|
||||
@action="handleAttributeAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="isEnabled">
|
||||
<ConversationRequiredEmpty
|
||||
v-if="conversationRequiredAttributes.length === 0"
|
||||
/>
|
||||
|
||||
<ConversationRequiredAttributeItem
|
||||
v-for="attribute in conversationRequiredAttributes"
|
||||
:key="attribute.value"
|
||||
:attribute="attribute"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<BasePaywallModal
|
||||
v-else
|
||||
class="mx-auto my-8"
|
||||
feature-prefix="CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES"
|
||||
:i18n-key="i18nKey"
|
||||
:is-on-chatwoot-cloud="isOnChatwootCloud"
|
||||
:is-super-admin="isSuperAdmin"
|
||||
@upgrade="goToBillingSettings"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
<script setup>
|
||||
import { ref, computed, reactive } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, url, helpers } from '@vuelidate/validators';
|
||||
import { getRegexp } from 'shared/helpers/Validators';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import TextArea from 'next/textarea/TextArea.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ChoiceToggle from 'dashboard/components-next/input/ChoiceToggle.vue';
|
||||
import { ATTRIBUTE_TYPES } from './constants';
|
||||
|
||||
const emit = defineEmits(['submit']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const visibleAttributes = ref([]);
|
||||
const formValues = reactive({});
|
||||
const conversationContext = ref(null);
|
||||
|
||||
const placeholders = computed(() => ({
|
||||
text: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.TEXT'),
|
||||
number: t(
|
||||
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.NUMBER'
|
||||
),
|
||||
link: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LINK'),
|
||||
date: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.DATE'),
|
||||
list: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LIST'),
|
||||
}));
|
||||
|
||||
const getPlaceholder = type => placeholders.value[type] || '';
|
||||
|
||||
const validationRules = computed(() => {
|
||||
const rules = {};
|
||||
visibleAttributes.value.forEach(attribute => {
|
||||
if (attribute.type === ATTRIBUTE_TYPES.LINK) {
|
||||
rules[attribute.value] = { required, url };
|
||||
} else if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
|
||||
// Checkbox doesn't need validation - any selection is valid
|
||||
rules[attribute.value] = {};
|
||||
} else {
|
||||
rules[attribute.value] = { required };
|
||||
if (attribute.regexPattern) {
|
||||
rules[attribute.value].regexValidation = helpers.withParams(
|
||||
{ regexCue: attribute.regexCue },
|
||||
value => !value || getRegexp(attribute.regexPattern).test(value)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
return rules;
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(validationRules, formValues);
|
||||
|
||||
const getErrorMessage = attributeKey => {
|
||||
const field = v$.value[attributeKey];
|
||||
if (!field || !field.$error) return '';
|
||||
|
||||
if (field.url && field.url.$invalid) {
|
||||
return t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
|
||||
}
|
||||
if (field.regexValidation && field.regexValidation.$invalid) {
|
||||
return (
|
||||
field.regexValidation.$params?.regexCue ||
|
||||
t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
|
||||
);
|
||||
}
|
||||
if (field.required && field.required.$invalid) {
|
||||
return t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const isFormComplete = computed(() =>
|
||||
visibleAttributes.value.every(attribute => {
|
||||
const value = formValues[attribute.value];
|
||||
|
||||
// For checkbox attributes, ensure the agent has explicitly selected a value
|
||||
if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
|
||||
return formValues[attribute.value] !== null;
|
||||
}
|
||||
|
||||
// For other attribute types, check for valid non-empty values
|
||||
return value !== undefined && value !== null && String(value).trim() !== '';
|
||||
})
|
||||
);
|
||||
|
||||
const comboBoxOptions = computed(() => {
|
||||
const options = {};
|
||||
visibleAttributes.value.forEach(attribute => {
|
||||
if (attribute.type === ATTRIBUTE_TYPES.LIST) {
|
||||
options[attribute.value] = (attribute.attributeValues || []).map(
|
||||
option => ({
|
||||
value: option,
|
||||
label: option,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
return options;
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
conversationContext.value = null;
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const open = (attributes = [], initialValues = {}, context = null) => {
|
||||
visibleAttributes.value = attributes;
|
||||
conversationContext.value = context;
|
||||
|
||||
// Clear existing formValues
|
||||
Object.keys(formValues).forEach(key => {
|
||||
delete formValues[key];
|
||||
});
|
||||
|
||||
// Initialize form values
|
||||
attributes.forEach(attribute => {
|
||||
const presetValue = initialValues[attribute.value];
|
||||
if (presetValue !== undefined && presetValue !== null) {
|
||||
formValues[attribute.value] = presetValue;
|
||||
} else {
|
||||
// For checkbox attributes, initialize to null to avoid pre-selection
|
||||
// For other attributes, initialize to empty string
|
||||
formValues[attribute.value] =
|
||||
attribute.type === ATTRIBUTE_TYPES.CHECKBOX ? null : '';
|
||||
}
|
||||
});
|
||||
|
||||
v$.value.$reset();
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
attributes: { ...formValues },
|
||||
context: conversationContext.value,
|
||||
});
|
||||
close();
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="lg"
|
||||
:title="t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.TITLE')"
|
||||
:description="
|
||||
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.DESCRIPTION')
|
||||
"
|
||||
:confirm-button-label="
|
||||
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.RESOLVE')
|
||||
"
|
||||
:cancel-button-label="
|
||||
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.CANCEL')
|
||||
"
|
||||
:disable-confirm-button="!isFormComplete"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="attribute in visibleAttributes"
|
||||
:key="attribute.value"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ attribute.label }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="attribute.type === ATTRIBUTE_TYPES.TEXT">
|
||||
<TextArea
|
||||
v-model="formValues[attribute.value]"
|
||||
class="w-full"
|
||||
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.TEXT)"
|
||||
:message="getErrorMessage(attribute.value)"
|
||||
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
|
||||
@blur="v$[attribute.value].$touch"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.NUMBER">
|
||||
<Input
|
||||
v-model="formValues[attribute.value]"
|
||||
type="number"
|
||||
size="md"
|
||||
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.NUMBER)"
|
||||
:message="getErrorMessage(attribute.value)"
|
||||
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
|
||||
@blur="v$[attribute.value].$touch"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.LINK">
|
||||
<Input
|
||||
v-model="formValues[attribute.value]"
|
||||
type="url"
|
||||
size="md"
|
||||
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.LINK)"
|
||||
:message="getErrorMessage(attribute.value)"
|
||||
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
|
||||
@blur="v$[attribute.value].$touch"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.DATE">
|
||||
<Input
|
||||
v-model="formValues[attribute.value]"
|
||||
type="date"
|
||||
size="md"
|
||||
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.DATE)"
|
||||
:message="getErrorMessage(attribute.value)"
|
||||
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
|
||||
@blur="v$[attribute.value].$touch"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.LIST">
|
||||
<ComboBox
|
||||
v-model="formValues[attribute.value]"
|
||||
:options="comboBoxOptions[attribute.value]"
|
||||
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.LIST)"
|
||||
:message="getErrorMessage(attribute.value)"
|
||||
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
|
||||
:has-error="v$[attribute.value].$error"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.CHECKBOX">
|
||||
<ChoiceToggle v-model="formValues[attribute.value]" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
export const ATTRIBUTE_TYPES = {
|
||||
TEXT: 'text',
|
||||
NUMBER: 'number',
|
||||
LINK: 'link',
|
||||
DATE: 'date',
|
||||
LIST: 'list',
|
||||
CHECKBOX: 'checkbox',
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const options = computed(() => [
|
||||
{ label: t('CHOICE_TOGGLE.YES'), value: true },
|
||||
{ label: t('CHOICE_TOGGLE.NO'), value: false },
|
||||
]);
|
||||
|
||||
const handleSelect = value => {
|
||||
emit('update:modelValue', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex gap-4 items-center px-4 py-2.5 w-full rounded-lg divide-x transition-colors bg-n-solid-1 outline outline-1 outline-n-weak hover:outline-n-slate-6 focus-within:outline-n-brand divide-n-weak"
|
||||
>
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="flex flex-1 gap-2 justify-center items-center"
|
||||
>
|
||||
<label class="inline-flex gap-2 items-center text-base cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
:value="option.value"
|
||||
:checked="modelValue === option.value"
|
||||
class="size-4 accent-n-blue-9 text-n-blue-9"
|
||||
@change="handleSelect(option.value)"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12">{{ option.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -529,7 +529,7 @@ const menuItems = computed(() => {
|
||||
{
|
||||
name: 'Settings Automation',
|
||||
label: t('SIDEBAR.AUTOMATION'),
|
||||
icon: 'i-lucide-workflow',
|
||||
icon: 'i-lucide-repeat',
|
||||
to: accountScopedRoute('automation_list'),
|
||||
},
|
||||
{
|
||||
@@ -574,6 +574,12 @@ const menuItems = computed(() => {
|
||||
icon: 'i-lucide-clock-alert',
|
||||
to: accountScopedRoute('sla_list'),
|
||||
},
|
||||
{
|
||||
name: 'Conversation Workflow',
|
||||
label: t('SIDEBAR.CONVERSATION_WORKFLOW'),
|
||||
icon: 'i-lucide-workflow',
|
||||
to: accountScopedRoute('conversation_workflow_index'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Security',
|
||||
label: t('SIDEBAR.SECURITY'),
|
||||
@@ -655,7 +661,7 @@ const menuItems = computed(() => {
|
||||
</ul>
|
||||
</nav>
|
||||
<section
|
||||
class="flex flex-col flex-shrink-0 relative gap-1 justify-between items-center"
|
||||
class="flex relative flex-col flex-shrink-0 gap-1 justify-between items-center"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
||||
|
||||
@@ -32,7 +32,7 @@ const props = defineProps({
|
||||
allowSignature: { type: Boolean, default: false }, // allowSignature is a kill switch, ensuring no signature methods are triggered except when this flag is true
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const emit = defineEmits(['update:modelValue', 'blur', 'focus']);
|
||||
|
||||
const textareaRef = ref(null);
|
||||
const isFocused = ref(false);
|
||||
@@ -96,15 +96,17 @@ const handleInput = event => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
const handleFocus = event => {
|
||||
if (!props.disabled) {
|
||||
isFocused.value = true;
|
||||
emit('focus', event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
const handleBlur = event => {
|
||||
if (!props.disabled) {
|
||||
isFocused.value = false;
|
||||
emit('blur', event);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import ConversationBulkActions from './widgets/conversation/conversationBulkActi
|
||||
import IntersectionObserver from './IntersectionObserver.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
} from 'dashboard/composables/useTransformKeys';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
@@ -87,6 +89,7 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
const conversationListRef = ref(null);
|
||||
const conversationDynamicScroller = ref(null);
|
||||
|
||||
@@ -129,6 +132,7 @@ const labels = useMapGetter('labels/getLabels');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
// We can't useFunctionGetter here since it needs to be called on setup?
|
||||
const getTeamFn = useMapGetter('teams/getTeam');
|
||||
const getConversationById = useMapGetter('getConversationById');
|
||||
|
||||
useChatListKeyboardEvents(conversationListRef);
|
||||
const {
|
||||
@@ -153,6 +157,8 @@ const {
|
||||
attributeModel: 'conversation_attribute',
|
||||
});
|
||||
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
// computed
|
||||
const intersectionObserverOptions = computed(() => {
|
||||
return {
|
||||
@@ -729,22 +735,76 @@ async function onAssignTeam(team, conversationId = null) {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleConversationStatus(conversationId, status, snoozedUntil) {
|
||||
store
|
||||
.dispatch('toggleStatus', {
|
||||
conversationId,
|
||||
status,
|
||||
function toggleConversationStatus(
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
customAttributes = null
|
||||
) {
|
||||
const payload = {
|
||||
conversationId,
|
||||
status,
|
||||
snoozedUntil,
|
||||
};
|
||||
|
||||
if (customAttributes) {
|
||||
payload.customAttributes = customAttributes;
|
||||
}
|
||||
|
||||
store.dispatch('toggleStatus', payload).then(() => {
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
});
|
||||
}
|
||||
|
||||
function handleResolveConversation(conversationId, status, snoozedUntil) {
|
||||
if (status !== wootConstants.STATUS_TYPE.RESOLVED) {
|
||||
toggleConversationStatus(conversationId, status, snoozedUntil);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for required attributes before resolving
|
||||
const conversation = getConversationById.value(conversationId);
|
||||
const currentCustomAttributes = conversation?.custom_attributes || {};
|
||||
const { hasMissing, missing } = checkMissingAttributes(
|
||||
currentCustomAttributes
|
||||
);
|
||||
|
||||
if (hasMissing) {
|
||||
// Pass conversation context through the modal's API
|
||||
const conversationContext = {
|
||||
id: conversationId,
|
||||
snoozedUntil,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
});
|
||||
};
|
||||
resolveAttributesModalRef.value?.open(
|
||||
missing,
|
||||
currentCustomAttributes,
|
||||
conversationContext
|
||||
);
|
||||
} else {
|
||||
toggleConversationStatus(conversationId, status, snoozedUntil);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResolveWithAttributes({ attributes, context }) {
|
||||
if (context) {
|
||||
const existingConversation = getConversationById.value(context.id);
|
||||
const currentCustomAttributes =
|
||||
existingConversation?.custom_attributes || {};
|
||||
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
|
||||
|
||||
toggleConversationStatus(
|
||||
context.id,
|
||||
wootConstants.STATUS_TYPE.RESOLVED,
|
||||
context.snoozedUntil,
|
||||
mergedAttributes
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function allSelectedConversationsStatus(status) {
|
||||
if (!selectedConversations.value.length) return false;
|
||||
return selectedConversations.value.every(item => {
|
||||
return store.getters.getConversationById(item)?.status === status;
|
||||
return getConversationById.value(item)?.status === status;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -799,7 +859,7 @@ provide('deSelectConversation', deSelectConversation);
|
||||
provide('assignAgent', onAssignAgent);
|
||||
provide('assignTeam', onAssignTeam);
|
||||
provide('assignLabels', onAssignLabels);
|
||||
provide('updateConversationStatus', toggleConversationStatus);
|
||||
provide('updateConversationStatus', handleResolveConversation);
|
||||
provide('toggleContextMenu', onContextMenuToggle);
|
||||
provide('markAsUnread', markAsUnread);
|
||||
provide('markAsRead', markAsRead);
|
||||
@@ -895,7 +955,7 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
|
||||
<p
|
||||
v-if="!chatListLoading && !conversationList.length"
|
||||
class="flex items-center justify-center p-4 overflow-auto"
|
||||
class="flex overflow-auto justify-center items-center p-4"
|
||||
>
|
||||
{{ $t('CHAT_LIST.LIST.404') }}
|
||||
</p>
|
||||
@@ -915,14 +975,14 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
/>
|
||||
<div
|
||||
ref="conversationListRef"
|
||||
class="flex-1 overflow-hidden conversations-list hover:overflow-y-auto"
|
||||
class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
|
||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<DynamicScroller
|
||||
ref="conversationDynamicScroller"
|
||||
:items="conversationList"
|
||||
:min-item-size="24"
|
||||
class="w-full h-full overflow-auto"
|
||||
class="overflow-auto w-full h-full"
|
||||
>
|
||||
<template #default="{ item, index, active }">
|
||||
<!--
|
||||
@@ -997,5 +1057,9 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
@close="closeAdvanceFiltersModal"
|
||||
/>
|
||||
</TeleportWithDirection>
|
||||
<ConversationResolveAttributesModal
|
||||
ref="resolveAttributesModalRef"
|
||||
@submit="handleResolveWithAttributes"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
@@ -17,13 +18,16 @@ import {
|
||||
|
||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const arrowDownButtonRef = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
const closeDropdown = () => toggleDropdown(false);
|
||||
@@ -77,19 +81,36 @@ const openSnoozeModal = () => {
|
||||
ninja.open({ parent: 'snooze_conversation' });
|
||||
};
|
||||
|
||||
const toggleStatus = (status, snoozedUntil) => {
|
||||
const toggleStatus = (status, snoozedUntil, customAttributes = null) => {
|
||||
closeDropdown();
|
||||
isLoading.value = true;
|
||||
store
|
||||
.dispatch('toggleStatus', {
|
||||
conversationId: currentChat.value.id,
|
||||
status,
|
||||
snoozedUntil,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
isLoading.value = false;
|
||||
});
|
||||
|
||||
const payload = {
|
||||
conversationId: currentChat.value.id,
|
||||
status,
|
||||
snoozedUntil,
|
||||
};
|
||||
|
||||
if (customAttributes) {
|
||||
payload.customAttributes = customAttributes;
|
||||
}
|
||||
|
||||
store.dispatch('toggleStatus', payload).then(() => {
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
isLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const handleResolveWithAttributes = ({ attributes, context }) => {
|
||||
if (context) {
|
||||
const currentCustomAttributes = currentChat.value.custom_attributes || {};
|
||||
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
|
||||
toggleStatus(
|
||||
wootConstants.STATUS_TYPE.RESOLVED,
|
||||
context.snoozedUntil,
|
||||
mergedAttributes
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onCmdOpenConversation = () => {
|
||||
@@ -97,7 +118,24 @@ const onCmdOpenConversation = () => {
|
||||
};
|
||||
|
||||
const onCmdResolveConversation = () => {
|
||||
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
const currentCustomAttributes = currentChat.value.custom_attributes || {};
|
||||
const { hasMissing, missing } = checkMissingAttributes(
|
||||
currentCustomAttributes
|
||||
);
|
||||
|
||||
if (hasMissing) {
|
||||
const conversationContext = {
|
||||
id: currentChat.value.id,
|
||||
snoozedUntil: null,
|
||||
};
|
||||
resolveAttributesModalRef.value?.open(
|
||||
missing,
|
||||
currentCustomAttributes,
|
||||
conversationContext
|
||||
);
|
||||
} else {
|
||||
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
}
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
@@ -107,13 +145,13 @@ const keyboardEvents = {
|
||||
},
|
||||
'Alt+KeyE': {
|
||||
action: async () => {
|
||||
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
onCmdResolveConversation();
|
||||
},
|
||||
},
|
||||
'$mod+Alt+KeyE': {
|
||||
action: async event => {
|
||||
const { all, activeIndex, lastIndex } = getConversationParams();
|
||||
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
onCmdResolveConversation();
|
||||
|
||||
if (activeIndex < lastIndex) {
|
||||
all[activeIndex + 1].click();
|
||||
@@ -133,9 +171,9 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex items-center justify-end resolve-actions">
|
||||
<div class="flex relative justify-end items-center resolve-actions">
|
||||
<ButtonGroup
|
||||
class="rounded-lg shadow outline-1 outline flex-shrink-0"
|
||||
class="flex-shrink-0 rounded-lg shadow outline-1 outline"
|
||||
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
|
||||
>
|
||||
<Button
|
||||
@@ -212,5 +250,9 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
</WootDropdownItem>
|
||||
</WootDropdownMenu>
|
||||
</div>
|
||||
<ConversationResolveAttributesModal
|
||||
ref="resolveAttributesModalRef"
|
||||
@submit="handleResolveWithAttributes"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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,10 +3,13 @@ import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
export function useBulkActions() {
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const selectedConversations = useMapGetter(
|
||||
'bulkActions/getSelectedConversationIds'
|
||||
@@ -116,17 +119,61 @@ export function useBulkActions() {
|
||||
}
|
||||
|
||||
async function onUpdateConversations(status, snoozedUntil) {
|
||||
try {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: selectedConversations.value,
|
||||
fields: {
|
||||
status,
|
||||
let conversationIds = selectedConversations.value;
|
||||
let skippedCount = 0;
|
||||
|
||||
// If resolving, check for required attributes
|
||||
if (status === wootConstants.STATUS_TYPE.RESOLVED) {
|
||||
const { validIds, skippedIds } = selectedConversations.value.reduce(
|
||||
(acc, id) => {
|
||||
const conversation = store.getters.getConversationById(id);
|
||||
const currentCustomAttributes = conversation?.custom_attributes || {};
|
||||
const { hasMissing } = checkMissingAttributes(
|
||||
currentCustomAttributes
|
||||
);
|
||||
|
||||
if (!hasMissing) {
|
||||
acc.validIds.push(id);
|
||||
} else {
|
||||
acc.skippedIds.push(id);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
snoozed_until: snoozedUntil,
|
||||
});
|
||||
{ validIds: [], skippedIds: [] }
|
||||
);
|
||||
|
||||
conversationIds = validIds;
|
||||
skippedCount = skippedIds.length;
|
||||
|
||||
if (skippedCount > 0 && validIds.length === 0) {
|
||||
// All conversations have missing attributes
|
||||
useAlert(
|
||||
t('BULK_ACTION.RESOLVE.ALL_MISSING_ATTRIBUTES') ||
|
||||
'Cannot resolve conversations due to missing required attributes'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (conversationIds.length > 0) {
|
||||
await store.dispatch('bulkActions/process', {
|
||||
type: 'Conversation',
|
||||
ids: conversationIds,
|
||||
fields: {
|
||||
status,
|
||||
},
|
||||
snoozed_until: snoozedUntil,
|
||||
});
|
||||
}
|
||||
|
||||
store.dispatch('bulkActions/clearSelectedConversationIds');
|
||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
|
||||
|
||||
if (skippedCount > 0) {
|
||||
useAlert(t('BULK_ACTION.RESOLVE.PARTIAL_SUCCESS'));
|
||||
} else {
|
||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
|
||||
}
|
||||
} catch (err) {
|
||||
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useConversationRequiredAttributes } from '../useConversationRequiredAttributes';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables/useAccount');
|
||||
|
||||
const defaultAttributes = [
|
||||
{
|
||||
attributeKey: 'priority',
|
||||
attributeDisplayName: 'Priority',
|
||||
attributeDisplayType: 'list',
|
||||
attributeValues: ['High', 'Medium', 'Low'],
|
||||
},
|
||||
{
|
||||
attributeKey: 'category',
|
||||
attributeDisplayName: 'Category',
|
||||
attributeDisplayType: 'text',
|
||||
attributeValues: [],
|
||||
},
|
||||
{
|
||||
attributeKey: 'is_urgent',
|
||||
attributeDisplayName: 'Is Urgent',
|
||||
attributeDisplayType: 'checkbox',
|
||||
attributeValues: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe('useConversationRequiredAttributes', () => {
|
||||
beforeEach(() => {
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
if (getter === 'accounts/isFeatureEnabledonAccount') {
|
||||
return { value: () => true };
|
||||
}
|
||||
if (getter === 'attributes/getConversationAttributes') {
|
||||
return { value: defaultAttributes };
|
||||
}
|
||||
return { value: null };
|
||||
});
|
||||
|
||||
useAccount.mockReturnValue({
|
||||
currentAccount: {
|
||||
value: {
|
||||
settings: {
|
||||
conversation_required_attributes: [
|
||||
'priority',
|
||||
'category',
|
||||
'is_urgent',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: { value: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
const setupMocks = (
|
||||
requiredAttributes = ['priority', 'category', 'is_urgent'],
|
||||
{ attributes = defaultAttributes, featureEnabled = true } = {}
|
||||
) => {
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
if (getter === 'accounts/isFeatureEnabledonAccount') {
|
||||
return { value: () => featureEnabled };
|
||||
}
|
||||
if (getter === 'attributes/getConversationAttributes') {
|
||||
return { value: attributes };
|
||||
}
|
||||
return { value: null };
|
||||
});
|
||||
|
||||
useAccount.mockReturnValue({
|
||||
currentAccount: {
|
||||
value: {
|
||||
settings: {
|
||||
conversation_required_attributes: requiredAttributes,
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: { value: 1 },
|
||||
});
|
||||
};
|
||||
|
||||
describe('requiredAttributeKeys', () => {
|
||||
it('should return required attribute keys from account settings', () => {
|
||||
setupMocks();
|
||||
const { requiredAttributeKeys } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributeKeys.value).toEqual([
|
||||
'priority',
|
||||
'category',
|
||||
'is_urgent',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return empty array when no required attributes configured', () => {
|
||||
setupMocks([]);
|
||||
const { requiredAttributeKeys } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributeKeys.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when account settings is null', () => {
|
||||
setupMocks([], { attributes: [] });
|
||||
useAccount.mockReturnValue({
|
||||
currentAccount: { value: { settings: null } },
|
||||
accountId: { value: 1 },
|
||||
});
|
||||
|
||||
const { requiredAttributeKeys } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributeKeys.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requiredAttributes', () => {
|
||||
it('should return full attribute definitions for required attributes only', () => {
|
||||
setupMocks();
|
||||
const { requiredAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributes.value).toHaveLength(3);
|
||||
expect(requiredAttributes.value[0]).toEqual({
|
||||
attributeKey: 'priority',
|
||||
attributeDisplayName: 'Priority',
|
||||
attributeDisplayType: 'list',
|
||||
attributeValues: ['High', 'Medium', 'Low'],
|
||||
value: 'priority',
|
||||
label: 'Priority',
|
||||
type: 'list',
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out deleted attributes that no longer exist', () => {
|
||||
// Mock with only 2 attributes available but 3 required
|
||||
setupMocks(['priority', 'category', 'is_urgent'], {
|
||||
attributes: [
|
||||
{
|
||||
attributeKey: 'priority',
|
||||
attributeDisplayName: 'Priority',
|
||||
attributeDisplayType: 'list',
|
||||
attributeValues: ['High', 'Medium', 'Low'],
|
||||
},
|
||||
{
|
||||
attributeKey: 'is_urgent',
|
||||
attributeDisplayName: 'Is Urgent',
|
||||
attributeDisplayType: 'checkbox',
|
||||
attributeValues: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { requiredAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
expect(requiredAttributes.value).toHaveLength(2);
|
||||
expect(requiredAttributes.value.map(attr => attr.value)).toEqual([
|
||||
'priority',
|
||||
'is_urgent',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkMissingAttributes', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
it('should return no missing when all attributes are filled', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
is_urgent: true,
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
expect(result.all).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should detect missing text attributes', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
is_urgent: true,
|
||||
// category is missing
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing).toHaveLength(1);
|
||||
expect(result.missing[0].value).toBe('category');
|
||||
});
|
||||
|
||||
it('should detect empty string values as missing', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: '', // empty string
|
||||
is_urgent: true,
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing[0].value).toBe('category');
|
||||
});
|
||||
|
||||
it('should consider checkbox attribute present when value is true', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
is_urgent: true,
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should consider checkbox attribute present when value is false', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
is_urgent: false, // false is still considered "filled"
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect missing checkbox when key does not exist', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
// is_urgent key is completely missing
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing).toHaveLength(1);
|
||||
expect(result.missing[0].value).toBe('is_urgent');
|
||||
expect(result.missing[0].type).toBe('checkbox');
|
||||
});
|
||||
|
||||
it('should handle falsy values correctly for non-checkbox attributes', () => {
|
||||
setupMocks(['score', 'status_flag'], {
|
||||
attributes: [
|
||||
{
|
||||
attributeKey: 'score',
|
||||
attributeDisplayName: 'Score',
|
||||
attributeDisplayType: 'number',
|
||||
attributeValues: [],
|
||||
},
|
||||
{
|
||||
attributeKey: 'status_flag',
|
||||
attributeDisplayName: 'Status Flag',
|
||||
attributeDisplayType: 'text',
|
||||
attributeValues: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
score: 0, // zero should be considered valid, not missing
|
||||
status_flag: false, // false should be considered valid, not missing
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle null values as missing for text attributes', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: null, // null should be missing for text attribute
|
||||
is_urgent: true, // checkbox is present
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing).toHaveLength(1);
|
||||
expect(result.missing[0].value).toBe('category');
|
||||
});
|
||||
|
||||
it('should consider undefined checkbox values as present when key exists', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: 'Bug Report',
|
||||
is_urgent: undefined, // key exists but value is undefined - still considered "filled" for checkbox
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return no missing when no attributes are required', () => {
|
||||
setupMocks([]); // No required attributes
|
||||
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const result = checkMissingAttributes({});
|
||||
|
||||
expect(result.hasMissing).toBe(false);
|
||||
expect(result.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle whitespace-only values as missing', () => {
|
||||
const { checkMissingAttributes } = useConversationRequiredAttributes();
|
||||
|
||||
const customAttributes = {
|
||||
priority: 'High',
|
||||
category: ' ', // whitespace only
|
||||
is_urgent: true,
|
||||
};
|
||||
|
||||
const result = checkMissingAttributes(customAttributes);
|
||||
|
||||
expect(result.hasMissing).toBe(true);
|
||||
expect(result.missing[0].value).toBe('category');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,97 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { ATTRIBUTE_TYPES } from 'dashboard/components-next/ConversationWorkflow/constants';
|
||||
|
||||
/**
|
||||
* Composable for managing conversation required attributes workflow
|
||||
*
|
||||
* This handles the logic for checking if conversations have all required
|
||||
* custom attributes filled before they can be resolved.
|
||||
*/
|
||||
export function useConversationRequiredAttributes() {
|
||||
const { currentAccount, accountId } = useAccount();
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
const conversationAttributes = useMapGetter(
|
||||
'attributes/getConversationAttributes'
|
||||
);
|
||||
|
||||
const isFeatureEnabled = computed(() =>
|
||||
isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES
|
||||
)
|
||||
);
|
||||
|
||||
const requiredAttributeKeys = computed(() => {
|
||||
if (!isFeatureEnabled.value) return [];
|
||||
return (
|
||||
currentAccount.value?.settings?.conversation_required_attributes || []
|
||||
);
|
||||
});
|
||||
|
||||
const allAttributeOptions = computed(() =>
|
||||
(conversationAttributes.value || []).map(attribute => ({
|
||||
...attribute,
|
||||
value: attribute.attributeKey,
|
||||
label: attribute.attributeDisplayName,
|
||||
type: attribute.attributeDisplayType,
|
||||
attributeValues: attribute.attributeValues,
|
||||
}))
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the full attribute definitions for only the required attributes
|
||||
* Filters allAttributeOptions to only include attributes marked as required
|
||||
*/
|
||||
const requiredAttributes = computed(
|
||||
() =>
|
||||
requiredAttributeKeys.value
|
||||
.map(key =>
|
||||
allAttributeOptions.value.find(attribute => attribute.value === key)
|
||||
)
|
||||
.filter(Boolean) // Remove any undefined attributes (deleted attributes)
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if a conversation is missing any required attributes
|
||||
*
|
||||
* @param {Object} conversationCustomAttributes - Current conversation's custom attributes
|
||||
* @returns {Object} - Analysis result with missing attributes info
|
||||
*/
|
||||
const checkMissingAttributes = (conversationCustomAttributes = {}) => {
|
||||
// If no attributes are required, conversation can be resolved
|
||||
if (!requiredAttributes.value.length) {
|
||||
return { hasMissing: false, missing: [] };
|
||||
}
|
||||
|
||||
// Find attributes that are missing or empty
|
||||
const missing = requiredAttributes.value.filter(attribute => {
|
||||
const value = conversationCustomAttributes[attribute.value];
|
||||
|
||||
// For checkbox/boolean attributes, only check if the key exists
|
||||
if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
|
||||
return !(attribute.value in conversationCustomAttributes);
|
||||
}
|
||||
|
||||
// For other attribute types, only consider null, undefined, empty string, or whitespace-only as missing
|
||||
// Allow falsy values like 0, false as they are valid filled values
|
||||
return value == null || String(value).trim() === '';
|
||||
});
|
||||
|
||||
return {
|
||||
hasMissing: missing.length > 0,
|
||||
missing,
|
||||
all: requiredAttributes.value,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
requiredAttributeKeys,
|
||||
requiredAttributes,
|
||||
checkMissingAttributes,
|
||||
};
|
||||
}
|
||||
@@ -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,10 +40,12 @@ 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',
|
||||
ADVANCED_SEARCH: 'advanced_search',
|
||||
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
@@ -53,4 +55,5 @@ export const PREMIUM_FEATURES = [
|
||||
FEATURE_FLAGS.AUDIT_LOGS,
|
||||
FEATURE_FLAGS.HELP_CENTER,
|
||||
FEATURE_FLAGS.SAML,
|
||||
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -63,6 +63,10 @@
|
||||
},
|
||||
"ENABLE_REGEX": {
|
||||
"LABEL": "Enable regex validation"
|
||||
},
|
||||
"BADGES": {
|
||||
"PRE_CHAT": "Pre-chat",
|
||||
"RESOLUTION": "Resolution"
|
||||
}
|
||||
},
|
||||
"API": {
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
|
||||
"UPDATE_FAILED": "Failed to update conversations. Please try again."
|
||||
},
|
||||
"RESOLVE": {
|
||||
"ALL_MISSING_ATTRIBUTES": "Cannot resolve conversations due to missing required attributes",
|
||||
"PARTIAL_SUCCESS": "Some conversations need required attributes before resolving and were skipped"
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
|
||||
@@ -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,12 @@
|
||||
"CLOSE": "Close",
|
||||
"BETA": "Beta",
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
|
||||
"ACCEPT": "Accept",
|
||||
"DISCARD": "Discard",
|
||||
"PREFERRED": "Preferred"
|
||||
},
|
||||
"CHOICE_TOGGLE": {
|
||||
"YES": "Yes",
|
||||
"NO": "No"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -379,7 +379,8 @@
|
||||
},
|
||||
"DOCS": "Read docs",
|
||||
"SECURITY": "Security",
|
||||
"CAPTAIN_AI": "Captain"
|
||||
"CAPTAIN_AI": "Captain",
|
||||
"CONVERSATION_WORKFLOW": "Conversation Workflow"
|
||||
},
|
||||
"CAPTAIN_SETTINGS": {
|
||||
"TITLE": "Captain Settings",
|
||||
@@ -555,6 +556,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CONVERSATION_WORKFLOW": {
|
||||
"INDEX": {
|
||||
"HEADER": {
|
||||
"TITLE": "Conversation Workflows",
|
||||
"DESCRIPTION": "Configure rules and required fields for conversation resolution."
|
||||
}
|
||||
},
|
||||
"REQUIRED_ATTRIBUTES": {
|
||||
"TITLE": "Attributes required on resolution",
|
||||
"DESCRIPTION": "When resolving a conversation, agents will be prompted to fill these attributes if they haven't yet.",
|
||||
"NO_ATTRIBUTES": "No attributes added yet",
|
||||
"ADD": {
|
||||
"TITLE": "Add Attributes",
|
||||
"SEARCH_PLACEHOLDER": "Search attributes"
|
||||
},
|
||||
"SAVE": {
|
||||
"SUCCESS": "Required attributes updated",
|
||||
"ERROR": "Could not update required attributes, please try again"
|
||||
},
|
||||
"MODAL": {
|
||||
"TITLE": "Resolve conversation",
|
||||
"DESCRIPTION": "Please fill in the following custom attributes before resolving this conversation",
|
||||
"ACTIONS": {
|
||||
"RESOLVE": "Resolve conversation",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"TEXT": "Write a note...",
|
||||
"NUMBER": "Enter a number",
|
||||
"LINK": "Add a link",
|
||||
"DATE": "Pick a date",
|
||||
"LIST": "Select an option"
|
||||
},
|
||||
"CHECKBOX": {
|
||||
"YES": "Yes",
|
||||
"NO": "No"
|
||||
}
|
||||
},
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to use required attributes",
|
||||
"AVAILABLE_ON": "The required conversation attributes feature is available on the Business and Enterprise plans.",
|
||||
"UPGRADE_PROMPT": "Upgrade your plan to prompt agents to fill required attributes before conversation resolution.",
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||
},
|
||||
"ENTERPRISE_PAYWALL": {
|
||||
"AVAILABLE_ON": "The required conversation attributes feature is available on the paid plans.",
|
||||
"UPGRADE_PROMPT": "Upgrade to a paid plan to enforce required attributes before conversation resolution.",
|
||||
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
||||
}
|
||||
}
|
||||
},
|
||||
"CREATE_ACCOUNT": {
|
||||
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
|
||||
"NEW_ACCOUNT": "New Account",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+1
-4
@@ -76,10 +76,7 @@ const toggleButtonText = computed(() =>
|
||||
const filteredCustomAttributes = computed(() =>
|
||||
attributes.value.map(attribute => {
|
||||
// Check if the attribute key exists in customAttributes
|
||||
const hasValue = Object.hasOwnProperty.call(
|
||||
customAttributes.value,
|
||||
attribute.attribute_key
|
||||
);
|
||||
const hasValue = attribute.attribute_key in customAttributes.value;
|
||||
|
||||
return {
|
||||
...attribute,
|
||||
|
||||
@@ -14,7 +14,6 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import AccountId from './components/AccountId.vue';
|
||||
import BuildInfo from './components/BuildInfo.vue';
|
||||
import AccountDelete from './components/AccountDelete.vue';
|
||||
import AutoResolve from './components/AutoResolve.vue';
|
||||
import AudioTranscription from './components/AudioTranscription.vue';
|
||||
import SectionLayout from './components/SectionLayout.vue';
|
||||
|
||||
@@ -25,7 +24,6 @@ export default {
|
||||
AccountId,
|
||||
BuildInfo,
|
||||
AccountDelete,
|
||||
AutoResolve,
|
||||
AudioTranscription,
|
||||
SectionLayout,
|
||||
WithLabel,
|
||||
@@ -64,12 +62,6 @@ export default {
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
showAutoResolutionConfig() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
|
||||
);
|
||||
},
|
||||
showAudioTranscriptionConfig() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
@@ -239,7 +231,6 @@ export default {
|
||||
|
||||
<woot-loading-state v-if="uiFlags.isFetchingItem" />
|
||||
</div>
|
||||
<AutoResolve v-if="showAutoResolutionConfig" />
|
||||
<AudioTranscription v-if="showAudioTranscriptionConfig" />
|
||||
<AccountId />
|
||||
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
|
||||
|
||||
+80
-72
@@ -4,7 +4,6 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import SectionLayout from './SectionLayout.vue';
|
||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||
import TextArea from 'next/textarea/TextArea.vue';
|
||||
import Switch from 'next/switch/Switch.vue';
|
||||
@@ -125,81 +124,90 @@ const toggleAutoResolve = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SectionLayout
|
||||
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
|
||||
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
|
||||
:hide-content="!isEnabled"
|
||||
with-border
|
||||
<div
|
||||
class="flex flex-col w-full outline-1 outline outline-n-container rounded-xl bg-n-solid-2 divide-y divide-n-weak"
|
||||
>
|
||||
<template #headerActions>
|
||||
<div class="flex justify-end">
|
||||
<Switch v-model="isEnabled" @change="toggleAutoResolve" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form class="grid gap-5" @submit.prevent="handleSubmit">
|
||||
<WithLabel
|
||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL')"
|
||||
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.HELP')"
|
||||
>
|
||||
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
|
||||
<!-- allow 10 mins to 999 days -->
|
||||
<DurationInput
|
||||
v-model="duration"
|
||||
v-model:unit="unit"
|
||||
min="0"
|
||||
max="1438560"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="flex flex-col gap-2 items-start px-5 py-4">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE') }}
|
||||
</h3>
|
||||
<div class="flex justify-end">
|
||||
<Switch v-model="isEnabled" @change="toggleAutoResolve" />
|
||||
</div>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
|
||||
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
|
||||
>
|
||||
<TextArea
|
||||
v-model="message"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</WithLabel>
|
||||
<WithLabel :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.PREFERENCES')">
|
||||
<div
|
||||
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
|
||||
</div>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isEnabled" class="px-5 py-4">
|
||||
<form class="grid gap-5" @submit.prevent="handleSubmit">
|
||||
<WithLabel
|
||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL')"
|
||||
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.HELP')"
|
||||
>
|
||||
<div class="p-3 h-12 flex items-center justify-between">
|
||||
<span>
|
||||
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL') }}
|
||||
</span>
|
||||
<Switch v-model="ignoreWaiting" />
|
||||
</div>
|
||||
<div class="p-3 h-12 flex items-center justify-between">
|
||||
<span>
|
||||
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.LABEL') }}
|
||||
</span>
|
||||
<SingleSelect
|
||||
v-model="labelToApply"
|
||||
:options="labelOptions"
|
||||
:placeholder="
|
||||
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.PLACEHOLDER')
|
||||
"
|
||||
placeholder-icon="i-lucide-chevron-down"
|
||||
placeholder-trailing-icon
|
||||
variant="faded"
|
||||
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
|
||||
<!-- allow 10 mins to 999 days -->
|
||||
<DurationInput
|
||||
v-model="duration"
|
||||
v-model:unit="unit"
|
||||
min="0"
|
||||
max="1438560"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</WithLabel>
|
||||
<WithLabel
|
||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
|
||||
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
|
||||
>
|
||||
<TextArea
|
||||
v-model="message"
|
||||
class="w-full"
|
||||
:placeholder="
|
||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</WithLabel>
|
||||
<WithLabel :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.PREFERENCES')">
|
||||
<div
|
||||
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
|
||||
>
|
||||
<div class="p-3 h-12 flex items-center justify-between">
|
||||
<span>
|
||||
{{
|
||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL')
|
||||
}}
|
||||
</span>
|
||||
<Switch v-model="ignoreWaiting" />
|
||||
</div>
|
||||
<div class="p-3 h-12 flex items-center justify-between">
|
||||
<span>
|
||||
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.LABEL') }}
|
||||
</span>
|
||||
<SingleSelect
|
||||
v-model="labelToApply"
|
||||
:options="labelOptions"
|
||||
:placeholder="
|
||||
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.PLACEHOLDER')
|
||||
"
|
||||
placeholder-icon="i-lucide-chevron-down"
|
||||
placeholder-trailing-icon
|
||||
variant="faded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</WithLabel>
|
||||
<div class="flex gap-2">
|
||||
<NextButton
|
||||
blue
|
||||
type="submit"
|
||||
:is-loading="isSubmitting"
|
||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON')"
|
||||
/>
|
||||
</div>
|
||||
</WithLabel>
|
||||
<div class="flex gap-2">
|
||||
<NextButton
|
||||
blue
|
||||
type="submit"
|
||||
:is-loading="isSubmitting"
|
||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</SectionLayout>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
<script setup>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import EditAttribute from './EditAttribute.vue';
|
||||
import { useStoreGetters, useStore } from 'dashboard/composables/store';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attributeModel: {
|
||||
type: String,
|
||||
default: 'conversation_attribute',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const showEditPopup = ref(false);
|
||||
const showDeletePopup = ref(false);
|
||||
const selectedAttribute = ref({});
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const store = useStore();
|
||||
|
||||
const attributes = computed(() =>
|
||||
getters['attributes/getAttributesByModel'].value(props.attributeModel)
|
||||
);
|
||||
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
|
||||
|
||||
const attributeDisplayName = computed(
|
||||
() => selectedAttribute.value.attribute_display_name
|
||||
);
|
||||
const deleteConfirmText = computed(
|
||||
() =>
|
||||
`${t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${attributeDisplayName.value}`
|
||||
);
|
||||
const deleteRejectText = computed(() => t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO'));
|
||||
const confirmDeleteTitle = computed(() =>
|
||||
t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
|
||||
attributeName: attributeDisplayName.value,
|
||||
})
|
||||
);
|
||||
const confirmPlaceHolderText = computed(
|
||||
() =>
|
||||
`${t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
|
||||
attributeName: attributeDisplayName.value,
|
||||
})}`
|
||||
);
|
||||
|
||||
const deleteAttributes = async ({ id }) => {
|
||||
try {
|
||||
await store.dispatch('attributes/delete', id);
|
||||
useAlert(t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message || t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
const openEditPopup = response => {
|
||||
showEditPopup.value = true;
|
||||
selectedAttribute.value = response;
|
||||
};
|
||||
const hideEditPopup = () => {
|
||||
showEditPopup.value = false;
|
||||
};
|
||||
|
||||
const closeDelete = () => {
|
||||
showDeletePopup.value = false;
|
||||
selectedAttribute.value = {};
|
||||
};
|
||||
const confirmDeletion = () => {
|
||||
deleteAttributes(selectedAttribute.value);
|
||||
closeDelete();
|
||||
};
|
||||
const openDelete = value => {
|
||||
showDeletePopup.value = true;
|
||||
selectedAttribute.value = value;
|
||||
};
|
||||
|
||||
const tableHeaders = computed(() => {
|
||||
return [
|
||||
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.NAME'),
|
||||
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.DESCRIPTION'),
|
||||
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.TYPE'),
|
||||
t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER.KEY'),
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<table class="min-w-full overflow-x-auto">
|
||||
<thead>
|
||||
<th
|
||||
v-for="tableHeader in tableHeaders"
|
||||
:key="tableHeader"
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11"
|
||||
>
|
||||
{{ tableHeader }}
|
||||
</th>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-n-weak flex-1 text-n-slate-12">
|
||||
<tr v-for="attribute in attributes" :key="attribute.attribute_key">
|
||||
<td
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ attribute.attribute_display_name }}
|
||||
</td>
|
||||
<td class="py-4 ltr:pr-4 rtl:pl-4">
|
||||
{{ attribute.attribute_description }}
|
||||
</td>
|
||||
<td
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${attribute.attribute_display_type?.toUpperCase()}`
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
<td
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 attribute-key overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ attribute.attribute_key }}
|
||||
</td>
|
||||
<td class="py-4 min-w-xs">
|
||||
<div class="flex gap-1 justify-end">
|
||||
<Button
|
||||
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.EDIT')"
|
||||
icon="i-lucide-pen"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="openEditPopup(attribute)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.DELETE')"
|
||||
icon="i-lucide-trash-2"
|
||||
xs
|
||||
ruby
|
||||
faded
|
||||
@click="openDelete(attribute)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
|
||||
<EditAttribute
|
||||
:selected-attribute="selectedAttribute"
|
||||
:is-updating="uiFlags.isUpdating"
|
||||
@on-close="hideEditPopup"
|
||||
/>
|
||||
</woot-modal>
|
||||
<woot-confirm-delete-modal
|
||||
v-if="showDeletePopup"
|
||||
v-model:show="showDeletePopup"
|
||||
:title="confirmDeleteTitle"
|
||||
:message="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.MESSAGE')"
|
||||
:confirm-text="deleteConfirmText"
|
||||
:reject-text="deleteRejectText"
|
||||
:confirm-value="selectedAttribute.attribute_display_name"
|
||||
:confirm-place-holder-text="confirmPlaceHolderText"
|
||||
@on-confirm="confirmDeletion"
|
||||
@on-close="closeDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.attribute-key {
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import ConversationWorkflowIndex from './index.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/conversation-workflow'),
|
||||
component: SettingsWrapper,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'conversation_workflow_index',
|
||||
component: ConversationWorkflowIndex,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import ConversationRequiredAttributes from 'dashboard/components-next/ConversationWorkflow/ConversationRequiredAttributes.vue';
|
||||
import AutoResolve from 'dashboard/routes/dashboard/settings/account/components/AutoResolve.vue';
|
||||
|
||||
const { accountId } = useAccount();
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const showAutoResolutionConfig = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
|
||||
);
|
||||
});
|
||||
|
||||
const showRequiredAttributes = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout :no-records-found="false" class="gap-10">
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('CONVERSATION_WORKFLOW.INDEX.HEADER.TITLE')"
|
||||
:description="$t('CONVERSATION_WORKFLOW.INDEX.HEADER.DESCRIPTION')"
|
||||
feature-name="conversation-workflow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-6">
|
||||
<AutoResolve v-if="showAutoResolutionConfig" />
|
||||
<ConversationRequiredAttributes :is-enabled="showRequiredAttributes" />
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
@@ -24,6 +24,7 @@ import teams from './teams/teams.routes';
|
||||
import customRoles from './customRoles/customRole.routes';
|
||||
import profile from './profile/profile.routes';
|
||||
import security from './security/security.routes';
|
||||
import conversationWorkflow from './conversationWorkflow/conversationWorkflow.routes';
|
||||
import captain from './captain/captain.routes';
|
||||
|
||||
export default {
|
||||
@@ -64,6 +65,7 @@ export default {
|
||||
...customRoles.routes,
|
||||
...profile.routes,
|
||||
...security.routes,
|
||||
...conversationWorkflow.routes,
|
||||
...captain.routes,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -240,9 +240,21 @@ const actions = {
|
||||
|
||||
toggleStatus: async (
|
||||
{ commit },
|
||||
{ conversationId, status, snoozedUntil = null }
|
||||
{ conversationId, status, snoozedUntil = null, customAttributes = null }
|
||||
) => {
|
||||
try {
|
||||
// Update custom attributes first if provided
|
||||
if (customAttributes) {
|
||||
await ConversationApi.updateCustomAttributes({
|
||||
conversationId,
|
||||
customAttributes,
|
||||
});
|
||||
commit(types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES, {
|
||||
conversationId,
|
||||
customAttributes,
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
data: {
|
||||
payload: {
|
||||
@@ -459,7 +471,10 @@ const actions = {
|
||||
customAttributes,
|
||||
});
|
||||
const { custom_attributes } = response.data;
|
||||
commit(types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES, custom_attributes);
|
||||
commit(types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES, {
|
||||
conversationId,
|
||||
customAttributes: custom_attributes,
|
||||
});
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
|
||||
@@ -121,9 +121,19 @@ export const mutations = {
|
||||
chat.priority = priority;
|
||||
},
|
||||
|
||||
[types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES](_state, custom_attributes) {
|
||||
const [chat] = getSelectedChatConversation(_state);
|
||||
chat.custom_attributes = custom_attributes;
|
||||
[types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES](
|
||||
_state,
|
||||
{ conversationId, customAttributes }
|
||||
) {
|
||||
const conversation = _state.allConversations.find(
|
||||
c => c.id === conversationId
|
||||
);
|
||||
if (conversation) {
|
||||
conversation.custom_attributes = {
|
||||
...conversation.custom_attributes,
|
||||
...customAttributes,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
[types.CHANGE_CONVERSATION_STATUS](
|
||||
|
||||
@@ -548,7 +548,13 @@ describe('#deleteMessage', () => {
|
||||
}
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES, { order_d: '1001' }],
|
||||
[
|
||||
types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES,
|
||||
{
|
||||
conversationId: 1,
|
||||
customAttributes: { order_d: '1001' },
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,14 +239,14 @@ describe('#mutations', () => {
|
||||
describe('#UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES', () => {
|
||||
it('update conversation custom attributes', () => {
|
||||
const custom_attributes = { order_id: 1001 };
|
||||
const state = { allConversations: [{ id: 1 }], selectedChatId: 1 };
|
||||
const state = { allConversations: [{ id: 1, custom_attributes: {} }] };
|
||||
mutations[types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES](state, {
|
||||
conversationId: 1,
|
||||
custom_attributes,
|
||||
customAttributes: custom_attributes,
|
||||
});
|
||||
expect(
|
||||
state.allConversations[0].custom_attributes.custom_attributes
|
||||
).toEqual(custom_attributes);
|
||||
expect(state.allConversations[0].custom_attributes).toEqual(
|
||||
custom_attributes
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -85,7 +85,7 @@ class Account < ApplicationRecord
|
||||
|
||||
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
|
||||
|
||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label, :conversation_required_attributes
|
||||
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
|
||||
store_accessor :settings, :captain_models, :captain_features
|
||||
|
||||
has_many :account_users, dependent: :destroy_async
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,7 +45,6 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
belongs_to :account
|
||||
after_update :update_widget_pre_chat_custom_fields
|
||||
after_destroy :sync_widget_pre_chat_custom_fields
|
||||
after_destroy :cleanup_conversation_required_attributes
|
||||
|
||||
private
|
||||
|
||||
@@ -57,13 +56,6 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
::Inboxes::UpdateWidgetPreChatCustomFieldsJob.perform_later(account, self)
|
||||
end
|
||||
|
||||
def cleanup_conversation_required_attributes
|
||||
return unless conversation_attribute? && account.conversation_required_attributes&.include?(attribute_key)
|
||||
|
||||
account.conversation_required_attributes = account.conversation_required_attributes - [attribute_key]
|
||||
account.save!
|
||||
end
|
||||
|
||||
def attribute_must_not_conflict
|
||||
model_keys = attribute_model.to_sym == :conversation_attribute ? :conversation : :contact
|
||||
return unless attribute_key.in?(STANDARD_ATTRIBUTES[model_keys])
|
||||
@@ -71,3 +63,5 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
errors.add(:attribute_key, I18n.t('errors.custom_attribute_definition.key_conflict'))
|
||||
end
|
||||
end
|
||||
|
||||
CustomAttributeDefinition.include_mod_with('Concerns::CustomAttributeDefinition')
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user