feat(ee): Add Captain features (#10665)

Migration Guide: https://chwt.app/v4/migration

This PR imports all the work related to Captain into the EE codebase. Captain represents the AI-based features in Chatwoot and includes the following key components:

- Assistant: An assistant has a persona, the product it would be trained on. At the moment, the data at which it is trained is from websites. Future integrations on Notion documents, PDF etc. This PR enables connecting an assistant to an inbox. The assistant would run the conversation every time before transferring it to an agent.
- Copilot for Agents: When an agent is supporting a customer, we will be able to offer additional help to lookup some data or fetch information from integrations etc via copilot.
- Conversation FAQ generator: When a conversation is resolved, the Captain integration would identify questions which were not in the knowledge base.
- CRM memory: Learns from the conversations and identifies important information about the contact.

---------

Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Pranav
2025-01-14 16:15:47 -08:00
committed by GitHub
parent 7b31b5ad6e
commit d070743383
184 changed files with 6666 additions and 2242 deletions

View File

@@ -0,0 +1,19 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
}
get({ page = 1, searchKey } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
},
});
}
}
export default new CaptainAssistant();

View File

@@ -0,0 +1,19 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainDocument extends ApiClient {
constructor() {
super('captain/documents', { accountScoped: true });
}
get({ page = 1, searchKey } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
},
});
}
}
export default new CaptainDocument();

View File

@@ -0,0 +1,26 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainInboxes extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
}
get({ assistantId } = {}) {
return axios.get(`${this.url}/${assistantId}/inboxes`);
}
create(params = {}) {
const { assistantId, inboxId } = params;
return axios.post(`${this.url}/${assistantId}/inboxes`, {
inbox: { inbox_id: inboxId },
});
}
delete(params = {}) {
const { assistantId, inboxId } = params;
return axios.delete(`${this.url}/${assistantId}/inboxes/${inboxId}`);
}
}
export default new CaptainInboxes();

View File

@@ -0,0 +1,21 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainResponses extends ApiClient {
constructor() {
super('captain/assistant_responses', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId, documentId } = {}) {
return axios.get(this.url, {
params: {
page,
searchKey,
assistant_id: assistantId,
document_id: documentId,
},
});
}
}
export default new CaptainResponses();

View File

@@ -133,6 +133,10 @@ class ConversationApi extends ApiClient {
getAllAttachments(conversationId) {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
}
export default new ConversationApi();

View File

@@ -32,14 +32,6 @@ class IntegrationsAPI extends ApiClient {
deleteHook(hookId) {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
requestCaptain(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
}
requestCaptainCopilot(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/copilot`, body);
}
}
export default new IntegrationsAPI();

View File

@@ -29,7 +29,7 @@ const getWrittenBy = note => {
const isCurrentUser = note?.user?.id === currentUser.value.id;
return isCurrentUser
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note.user.name;
: note?.user?.name || 'Bot';
};
const onAdd = content => {

View File

@@ -33,8 +33,8 @@ const handleDelete = () => {
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
<Avatar
:name="note.user.name"
:src="note.user.thumbnail"
:name="note?.user?.name || 'Bot'"
:src="note?.user?.thumbnail || '/assets/images/chatwoot_bot.png'"
:size="16"
rounded-full
/>

View File

@@ -0,0 +1,84 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({
currentPage: {
type: Number,
default: 1,
},
totalCount: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 25,
},
headerTitle: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
showPaginationFooter: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['click', 'close', 'update:currentPage']);
const handleButtonClick = () => {
emit('click');
};
const handlePageChange = event => {
emit('update:currentPage', event);
};
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[960px] mx-auto">
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
>
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
<slot name="headerTitle" />
</span>
<div
v-on-clickaway="() => emit('close')"
class="relative group/campaign-button"
>
<Button
:label="buttonLabel"
icon="i-lucide-plus"
size="sm"
class="group-hover/campaign-button:brightness-110"
@click="handleButtonClick"
/>
<slot name="action" />
</div>
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-4">
<slot name="default" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="itemsPerPage"
@update:current-page="handlePageChange"
/>
</footer>
</section>
</template>

View File

@@ -0,0 +1,85 @@
<script setup>
import AssistantCard from './AssistantCard.vue';
const assistantList = [
{
account_id: 2,
config: { product_name: 'HelpDesk Pro' },
created_at: 1736033561,
description: 'An advanced assistant for customer support solutions',
id: 4,
name: 'Support Genie',
},
{
account_id: 3,
config: { product_name: 'CRM Tools' },
created_at: 1736033562,
description: 'Assists in managing customer relationships efficiently',
id: 5,
name: 'CRM Assistant',
},
{
account_id: 4,
config: { product_name: 'SalesFlow' },
created_at: 1736033563,
description: 'Optimizes sales pipeline tracking and forecasting',
id: 6,
name: 'SalesBot',
},
{
account_id: 5,
config: { product_name: 'TicketMaster AI' },
created_at: 1736033564,
description: 'Automates ticket assignment and customer query responses',
id: 7,
name: 'TicketBot',
},
{
account_id: 6,
config: { product_name: 'FinanceAssist' },
created_at: 1736033565,
description: 'Provides financial analytics and reporting',
id: 8,
name: 'Finance Wizard',
},
{
account_id: 7,
config: { product_name: 'MarketingMate' },
created_at: 1736033566,
description: 'Automates marketing tasks and generates campaign insights',
id: 9,
name: 'Marketing Guru',
},
{
account_id: 8,
config: { product_name: 'HR Assistant' },
created_at: 1736033567,
description: 'Streamlines HR operations and employee management',
id: 10,
name: 'HR Helper',
},
];
</script>
<template>
<Story
title="Captain/Assistant/AssistantCard"
:layout="{ type: 'grid', width: '700px' }"
>
<Variant title="Assistant Card">
<div
v-for="(assistant, index) in assistantList"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
>
<AssistantCard
:id="assistant.id"
:name="assistant.name"
:description="assistant.description"
:updated-at="assistant.updated_at || assistant.created_at"
:created-at="assistant.created_at"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,101 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
name: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
updatedAt: {
type: Number,
required: true,
},
});
const emit = defineEmits(['action']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const menuItems = computed(() => [
{
label: t('CAPTAIN.ASSISTANTS.OPTIONS.VIEW_CONNECTED_INBOXES'),
value: 'viewConnectedInboxes',
action: 'viewConnectedInboxes',
icon: 'i-lucide-link',
},
{
label: t('CAPTAIN.ASSISTANTS.OPTIONS.EDIT_ASSISTANT'),
value: 'edit',
action: 'edit',
icon: 'i-lucide-pencil-line',
},
{
label: t('CAPTAIN.ASSISTANTS.OPTIONS.DELETE_ASSISTANT'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
},
]);
const lastUpdatedAt = computed(() => dynamicTime(props.updatedAt));
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
</script>
<template>
<CardLayout>
<div class="flex justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ name }}
</span>
<div class="flex items-center gap-2">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
</div>
</div>
<div class="flex items-center justify-between w-full gap-4">
<span class="text-sm truncate text-n-slate-11">
{{ description || 'Description not available' }}
</span>
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
{{ lastUpdatedAt }}
</span>
</div>
</CardLayout>
</template>

View File

@@ -0,0 +1,171 @@
<script setup>
import DocumentCard from './DocumentCard.vue';
const documents = [
{
account_id: 1,
assistant: {
id: 1,
name: 'Helper Pro',
},
content: 'Guide content for using conversation filters.',
created_at: 1736143272,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688192-how-to-use-conversation-filters',
id: 3059,
name: 'How to use Conversation Filters? | User Guide | Chatwoot',
status: 'available',
},
{
account_id: 2,
assistant: {
id: 2,
name: 'Support Genie',
},
content: 'Guide on automating ticket assignments in Chatwoot.',
created_at: 1736143273,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688200-automating-ticket-assignments',
id: 3060,
name: 'Automating Ticket Assignments | User Guide | Chatwoot',
status: 'available',
},
{
account_id: 3,
assistant: {
id: 3,
name: 'CRM Assistant',
},
content: 'Learn how to manage customer profiles efficiently.',
created_at: 1736143274,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688210-managing-customer-profiles',
id: 3061,
name: 'Managing Customer Profiles | User Guide | Chatwoot',
status: 'available',
},
{
account_id: 4,
assistant: {
id: 4,
name: 'SalesBot',
},
content: 'Optimize sales tracking with advanced features.',
created_at: 1736143275,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688220-sales-tracking-guide',
id: 3062,
name: 'Sales Tracking Guide | User Guide | Chatwoot',
status: 'available',
},
{
account_id: 5,
assistant: {
id: 5,
name: 'TicketBot',
},
content: 'Learn how to create and manage tickets in Chatwoot.',
created_at: 1736143276,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688230-managing-tickets',
id: 3063,
name: 'Managing Tickets | User Guide | Chatwoot',
status: 'available',
},
{
account_id: 6,
assistant: {
id: 6,
name: 'Finance Wizard',
},
content: 'Guide on using financial reporting features.',
created_at: 1736143277,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688240-financial-reporting',
id: 3064,
name: 'Financial Reporting | User Guide | Chatwoot',
status: 'available',
},
{
account_id: 7,
assistant: {
id: 7,
name: 'Marketing Guru',
},
content: 'Learn about campaign automation in Chatwoot.',
created_at: 1736143278,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688250-campaign-automation',
id: 3065,
name: 'Campaign Automation | User Guide | Chatwoot',
status: 'available',
},
{
account_id: 8,
assistant: {
id: 8,
name: 'HR Helper',
},
content: 'How to manage employee profiles effectively.',
created_at: 1736143279,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688260-employee-profile-management',
id: 3066,
name: 'Employee Profile Management | User Guide | Chatwoot',
status: 'available',
},
{
account_id: 9,
assistant: {
id: 9,
name: 'ProjectBot',
},
content: 'Guide to project management features in Chatwoot.',
created_at: 1736143280,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688270-project-management',
id: 3067,
name: 'Project Management | User Guide | Chatwoot',
status: 'available',
},
{
account_id: 10,
assistant: {
id: 10,
name: 'ShopBot',
},
content: 'E-commerce optimization with Chatwoot features.',
created_at: 1736143281,
external_link:
'https://www.chatwoot.com/hc/user-guide/articles/1677688280-ecommerce-optimization',
id: 3068,
name: 'E-commerce Optimization | User Guide | Chatwoot',
status: 'available',
},
];
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Captain/Assistant/DocumentCard"
:layout="{ type: 'grid', width: '700px' }"
>
<Variant title="Document Card">
<div
v-for="(doc, index) in documents"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
>
<DocumentCard
:id="doc.id"
:name="doc.name"
:external-link="doc.external_link"
:assistant="doc.assistant"
:created-at="doc.created_at"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,108 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
name: {
type: String,
default: '',
},
assistant: {
type: Object,
default: () => ({}),
},
externalLink: {
type: String,
required: true,
},
createdAt: {
type: Number,
required: true,
},
});
const emit = defineEmits(['action']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const menuItems = computed(() => [
{
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
value: 'viewRelatedQuestions',
action: 'viewRelatedQuestions',
icon: 'i-ph-tree-view-duotone',
},
{
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
},
]);
const createdAt = computed(() => dynamicTime(props.createdAt));
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
</script>
<template>
<CardLayout>
<div class="flex justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ name }}
</span>
<div class="flex items-center gap-2">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
</div>
</div>
<div class="flex items-center justify-between w-full gap-4">
<span
class="text-sm shrink-0 truncate text-n-slate-11 flex items-center gap-1"
>
<i class="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<span
class="text-n-slate-11 text-sm truncate flex justify-start flex-1 items-center gap-1"
>
<i class="i-ph-link-simple shrink-0" />
<span class="truncate">{{ externalLink }}</span>
</span>
<div class="shrink-0 text-sm text-n-slate-11 line-clamp-1">
{{ createdAt }}
</div>
</div>
</CardLayout>
</template>

View File

@@ -0,0 +1,86 @@
<script setup>
import InboxCard from './InboxCard.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
const inboxes = [
{
id: 1,
name: 'Website Chat',
channel_type: INBOX_TYPES.WEB,
},
{
id: 2,
name: 'Facebook Support',
channel_type: INBOX_TYPES.FB,
},
{
id: 3,
name: 'Twitter Support',
channel_type: INBOX_TYPES.TWITTER,
},
{
id: 4,
name: 'SMS Support',
channel_type: INBOX_TYPES.TWILIO,
phone_number: '+1234567890',
},
{
id: 5,
name: 'SMS Service',
channel_type: INBOX_TYPES.TWILIO,
messaging_service_sid: 'MGxxxxxx',
},
{
id: 6,
name: 'WhatsApp Support',
channel_type: INBOX_TYPES.WHATSAPP,
phone_number: '+1987654321',
},
{
id: 7,
name: 'Email Support',
channel_type: INBOX_TYPES.EMAIL,
email: 'support@company.com',
},
{
id: 8,
name: 'Telegram Support',
channel_type: INBOX_TYPES.TELEGRAM,
},
{
id: 9,
name: 'LINE Support',
channel_type: INBOX_TYPES.LINE,
},
{
id: 10,
name: 'API Channel',
channel_type: INBOX_TYPES.API,
},
{
id: 11,
name: 'SMS Basic',
channel_type: INBOX_TYPES.SMS,
phone_number: '+1555555555',
},
];
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Captain/Assistant/InboxCard"
:layout="{ type: 'grid', width: '700px' }"
>
<Variant title="Inbox Card">
<div
v-for="inbox in inboxes"
:key="inbox.id"
class="px-20 py-4 bg-white dark:bg-slate-900"
>
<InboxCard :id="inbox.id" :inbox="inbox" />
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,100 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES, getInboxIconByType } from 'dashboard/helper/inbox';
const props = defineProps({
id: {
type: Number,
required: true,
},
inbox: {
type: Object,
required: true,
},
});
const emit = defineEmits(['action']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const inboxName = computed(() => {
const inbox = props.inbox;
if (!inbox?.name) {
return '';
}
const isTwilioChannel = inbox.channel_type === INBOX_TYPES.TWILIO;
const isWhatsAppChannel = inbox.channel_type === INBOX_TYPES.WHATSAPP;
const isEmailChannel = inbox.channel_type === INBOX_TYPES.EMAIL;
if (isTwilioChannel || isWhatsAppChannel) {
const identifier = inbox.messaging_service_sid || inbox.phone_number;
return identifier ? `${inbox.name} (${identifier})` : inbox.name;
}
if (isEmailChannel && inbox.email) {
return `${inbox.name} (${inbox.email})`;
}
return inbox.name;
});
const menuItems = computed(() => [
{
label: t('CAPTAIN.INBOXES.OPTIONS.DISCONNECT'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
},
]);
const icon = computed(() =>
getInboxIconByType(props.inbox.channel_type, '', 'outline')
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
</script>
<template>
<CardLayout>
<div class="flex justify-between w-full gap-1">
<span
class="text-base text-n-slate-12 line-clamp-1 flex items-center gap-2"
>
<span :class="icon" />
{{ inboxName }}
</span>
<div class="flex items-center gap-2">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
</div>
</div>
</CardLayout>
</template>

View File

@@ -0,0 +1,157 @@
<script setup>
import ResponseCard from './ResponseCard.vue';
const responses = [
{
account_id: 1,
answer:
'Messenger may be deactivated because you are on a free plan or the limit for inboxes might have been reached.',
created_at: 1736283330,
id: 87,
question: 'Why is my Messenger in Chatwoot deactivated?',
assistant: {
account_id: 1,
config: {
product_name: 'Chatwoot',
},
created_at: 1736033280,
description: 'This is a description of the assistant 2',
id: 1,
name: 'Assistant 2',
},
},
{
account_id: 2,
answer:
'You can integrate your WhatsApp account by navigating to the Integrations section and selecting the WhatsApp integration option.',
created_at: 1736283340,
id: 88,
question: 'How do I integrate WhatsApp with Chatwoot?',
assistant: {
account_id: 2,
config: {
product_name: 'Chatwoot',
},
created_at: 1736033281,
description: 'Handles integration queries',
id: 2,
name: 'Assistant 3',
},
},
{
account_id: 3,
answer:
"To reset your password, go to the login page and click on 'Forgot Password', then follow the instructions sent to your email.",
created_at: 1736283350,
id: 89,
question: 'How can I reset my password in Chatwoot?',
assistant: {
account_id: 3,
config: {
product_name: 'Chatwoot',
},
created_at: 1736033282,
description: 'Handles account management support',
id: 3,
name: 'Assistant 4',
},
},
{
account_id: 4,
answer:
"You can enable the dark mode in settings by navigating to 'Appearance' and selecting 'Dark Mode'.",
created_at: 1736283360,
id: 90,
question: 'How do I enable dark mode in Chatwoot?',
assistant: {
account_id: 4,
config: {
product_name: 'Chatwoot',
},
created_at: 1736033283,
description: 'Helps with UI customization',
id: 4,
name: 'Assistant 5',
},
},
{
account_id: 5,
answer:
"To add a new team member, navigate to 'Settings', then 'Team', and click on 'Add Team Member'.",
created_at: 1736283370,
id: 91,
question: 'How do I add a new team member in Chatwoot?',
assistant: {
account_id: 5,
config: {
product_name: 'Chatwoot',
},
created_at: 1736033284,
description: 'Handles team management queries',
id: 5,
name: 'Assistant 6',
},
},
{
account_id: 6,
answer:
"Campaigns in Chatwoot allow you to send targeted messages to specific user segments. You can create them in the 'Campaigns' section.",
created_at: 1736283380,
id: 92,
question: 'What are campaigns in Chatwoot?',
assistant: {
account_id: 6,
config: {
product_name: 'Chatwoot',
},
created_at: 1736033285,
description: 'Focuses on campaign and marketing queries',
id: 6,
name: 'Assistant 7',
},
},
{
account_id: 7,
answer:
"To track an agent's performance, use the Analytics dashboard under 'Reports'.",
created_at: 1736283390,
id: 93,
question: "How can I track an agent's performance in Chatwoot?",
assistant: {
account_id: 7,
config: {
product_name: 'Chatwoot',
},
created_at: 1736033286,
description: 'Analytics and reporting assistant',
id: 7,
name: 'Assistant 8',
},
},
];
</script>
<!-- eslint-disable vue/no-bare-strings-in-template -->
<!-- eslint-disable vue/no-undef-components -->
<template>
<Story
title="Captain/Assistant/ResponseCard"
:layout="{ type: 'grid', width: '700px' }"
>
<Variant title="Article Card">
<div
v-for="(response, index) in responses"
:key="index"
class="px-20 py-4 bg-white dark:bg-slate-900"
>
<ResponseCard
:id="response.id"
:question="response.question"
:answer="response.answer"
:assistant="response.assistant"
:created-at="response.created_at"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -0,0 +1,118 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
question: {
type: String,
required: true,
},
answer: {
type: String,
required: true,
},
compact: {
type: Boolean,
default: false,
},
assistant: {
type: Object,
default: () => ({}),
},
updatedAt: {
type: Number,
required: true,
},
createdAt: {
type: Number,
required: true,
},
});
const emit = defineEmits(['action']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const menuItems = computed(() => [
{
label: t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE'),
value: 'edit',
action: 'edit',
icon: 'i-lucide-pencil-line',
},
{
label: t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
},
]);
const timestamp = computed(() =>
dynamicTime(props.updatedAt || props.createdAt)
);
const handleAssistantAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
</script>
<template>
<CardLayout :class="{ 'rounded-md': compact }">
<div class="flex justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ question }}
</span>
<div v-if="!compact" class="flex items-center gap-2">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:right-0 top-full"
@action="handleAssistantAction($event)"
/>
</div>
</div>
</div>
<span class="text-n-slate-11 text-sm line-clamp-5">
{{ answer }}
</span>
<span v-if="!compact">
<span
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<div
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
>
<i class="i-ph-calendar-dot" />
{{ timestamp }}
</div>
</span>
</CardLayout>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
type: {
type: String,
required: true,
},
entity: {
type: Object,
required: true,
},
deletePayload: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const store = useStore();
const deleteDialogRef = ref(null);
const i18nKey = computed(() => props.type.toUpperCase());
const deleteEntity = async payload => {
if (!payload) return;
try {
await store.dispatch(`captain${props.type}/delete`, payload);
useAlert(t(`CAPTAIN.${i18nKey.value}.DELETE.SUCCESS_MESSAGE`));
} catch (error) {
useAlert(t(`CAPTAIN.${i18nKey.value}.DELETE.ERROR_MESSAGE`));
}
};
const handleDialogConfirm = async () => {
await deleteEntity(props.deletePayload || props.entity.id);
deleteDialogRef.value?.close();
};
defineExpose({ dialogRef: deleteDialogRef });
</script>
<template>
<Dialog
ref="deleteDialogRef"
type="alert"
:title="t(`CAPTAIN.${i18nKey}.DELETE.TITLE`)"
:description="t(`CAPTAIN.${i18nKey}.DELETE.DESCRIPTION`)"
:confirm-button-label="t(`CAPTAIN.${i18nKey}.DELETE.CONFIRM`)"
@confirm="handleDialogConfirm"
/>
</template>

View File

@@ -0,0 +1,174 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainAssistants/getUIFlags'),
};
const initialState = {
name: '',
description: '',
productName: '',
featureFaq: false,
featureMemory: false,
};
const state = reactive({ ...initialState });
const validationRules = {
name: { required, minLength: minLength(1) },
description: { required, minLength: minLength(1) },
productName: { required, minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.ASSISTANTS.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
name: getErrorMessage('name', 'NAME'),
description: getErrorMessage('description', 'DESCRIPTION'),
productName: getErrorMessage('productName', 'PRODUCT_NAME'),
}));
const handleCancel = () => emit('cancel');
const prepareAssistantDetails = () => ({
name: state.name,
description: state.description,
config: {
product_name: state.productName,
feature_faq: state.featureFaq,
feature_memory: state.featureMemory,
},
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) {
return;
}
emit('submit', prepareAssistantDetails());
};
const updateStateFromAssistant = assistant => {
if (!assistant) return;
const { name, description, config } = assistant;
Object.assign(state, {
name,
description,
productName: config.product_name,
featureFaq: config.feature_faq || false,
featureMemory: config.feature_memory || false,
});
};
watch(
() => props.assistant,
newAssistant => {
if (props.mode === 'edit' && newAssistant) {
updateStateFromAssistant(newAssistant);
}
},
{ immediate: true }
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.name"
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
:message="formErrors.name"
:message-type="formErrors.name ? 'error' : 'info'"
/>
<Editor
v-model="state.description"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
:message-type="formErrors.description ? 'error' : 'info'"
/>
<Input
v-model="state.productName"
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
:message="formErrors.productName"
:message-type="formErrors.productName ? 'error' : 'info'"
/>
<fieldset class="flex flex-col gap-2.5">
<legend class="mb-3 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
</legend>
<label class="flex items-center gap-2">
<input v-model="state.featureFaq" type="checkbox" />
<span class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS') }}
</span>
</label>
<label class="flex items-center gap-2">
<input v-model="state.featureMemory" type="checkbox" />
<span class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</span>
</label>
</fieldset>
<div class="flex items-center justify-between w-full gap-3">
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="t(`CAPTAIN.FORM.${mode.toUpperCase()}`)"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"
/>
</div>
</form>
</template>

View File

@@ -0,0 +1,87 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import AssistantForm from './AssistantForm.vue';
const props = defineProps({
selectedAssistant: {
type: Object,
default: () => ({}),
},
type: {
type: String,
default: 'create',
validator: value => ['create', 'edit'].includes(value),
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const assistantForm = ref(null);
const updateAssistant = assistantDetails =>
store.dispatch('captainAssistants/update', {
id: props.selectedAssistant.id,
...assistantDetails,
});
const i18nKey = computed(
() => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}`
);
const createAssistant = assistantDetails =>
store.dispatch('captainAssistants/create', assistantDetails);
const handleSubmit = async updatedAssistant => {
try {
if (props.type === 'edit') {
await updateAssistant(updatedAssistant);
} else {
await createAssistant(updatedAssistant);
}
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage = error?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleClose = () => {
emit('close');
};
const handleCancel = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t(`${i18nKey}.TITLE`)"
:description="t('CAPTAIN.ASSISTANTS.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
@close="handleClose"
>
<AssistantForm
ref="assistantForm"
:mode="type"
:assistant="selectedAssistant"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer />
</Dialog>
</template>

View File

@@ -0,0 +1,58 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DocumentForm from './DocumentForm.vue';
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const documentForm = ref(null);
const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
const handleSubmit = async newDocument => {
try {
await store.dispatch('captainDocuments/create', newDocument);
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage =
error?.response?.message || t(`${i18nKey}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleClose = () => {
emit('close');
};
const handleCancel = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.DOCUMENTS.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
>
<DocumentForm
ref="documentForm"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer />
</Dialog>
</template>

View File

@@ -0,0 +1,114 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, url } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainDocuments/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
};
const initialState = {
name: '',
assistantId: null,
};
const state = reactive({ ...initialState });
const validationRules = {
url: { required, url, minLength: minLength(1) },
assistantId: { required },
};
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
}))
);
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.DOCUMENTS.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
url: getErrorMessage('url', 'URL'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
}));
const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => ({
external_link: state.url,
assistant_id: state.assistantId,
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) {
return;
}
emit('submit', prepareDocumentDetails());
};
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.url"
:label="t('CAPTAIN.DOCUMENTS.FORM.URL.LABEL')"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.URL.PLACEHOLDER')"
:message="formErrors.url"
:message-type="formErrors.url ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
:message="formErrors.assistantId"
/>
</div>
<div class="flex items-center justify-between w-full gap-3">
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="t('CAPTAIN.FORM.CREATE')"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"
/>
</div>
</form>
</template>

View File

@@ -0,0 +1,68 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ResponseCard from '../../assistant/ResponseCard.vue';
const props = defineProps({
captainDocument: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responses = useMapGetter('captainResponses/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const handleClose = () => {
emit('close');
};
onMounted(() => {
store.dispatch('captainResponses/get', {
assistantId: props.captainDocument.assistant.id,
documentId: props.captainDocument.id,
});
});
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')"
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
width="3xl"
@close="handleClose"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else class="flex flex-col gap-3 min-h-48">
<ResponseCard
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
:answer="response.answer"
:assistant="response.assistant"
:created-at="response.created_at"
:updated-at="response.updated_at"
compact
/>
</div>
</Dialog>
</template>

View File

@@ -0,0 +1,65 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConnectInboxForm from './ConnectInboxForm.vue';
defineProps({
assistantId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const connectForm = ref(null);
const i18nKey = 'CAPTAIN.INBOXES.CREATE';
const handleSubmit = async payload => {
try {
await store.dispatch('captainInboxes/create', payload);
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage = error?.message || t(`${i18nKey}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleClose = () => {
emit('close');
};
const handleCancel = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="create"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.INBOXES.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
>
<ConnectInboxForm
ref="connectForm"
:assistant-id="assistantId"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer />
</Dialog>
</template>

View File

@@ -0,0 +1,115 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({
assistantId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainInboxes/getUIFlags'),
inboxes: useMapGetter('inboxes/getInboxes'),
captainInboxes: useMapGetter('captainInboxes/getRecords'),
};
const initialState = {
inboxId: null,
};
const state = reactive({ ...initialState });
const validationRules = {
inboxId: { required },
};
const inboxList = computed(() => {
const captainInboxIds = formState.captainInboxes.value.map(inbox => inbox.id);
return formState.inboxes.value
.filter(inbox => !captainInboxIds.includes(inbox.id))
.map(inbox => ({
value: inbox.id,
label: inbox.name,
}));
});
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.INBOXES.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
inboxId: getErrorMessage('inboxId', 'INBOX'),
}));
const handleCancel = () => emit('cancel');
const prepareInboxPayload = () => ({
inboxId: state.inboxId,
assistantId: props.assistantId,
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) {
return;
}
emit('submit', prepareInboxPayload());
};
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.INBOXES.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxList"
:has-error="!!formErrors.inboxId"
:placeholder="t('CAPTAIN.INBOXES.FORM.INBOX.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
:message="formErrors.inboxId"
/>
</div>
<div class="flex items-center justify-between w-full gap-3">
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="t('CAPTAIN.FORM.CREATE')"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"
/>
</div>
</form>
</template>

View File

@@ -0,0 +1,84 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ResponseForm from './ResponseForm.vue';
const props = defineProps({
selectedResponse: {
type: Object,
default: () => ({}),
},
type: {
type: String,
default: 'create',
validator: value => ['create', 'edit'].includes(value),
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const responseForm = ref(null);
const updateResponse = responseDetails =>
store.dispatch('captainResponses/update', {
id: props.selectedResponse.id,
...responseDetails,
});
const i18nKey = computed(() => `CAPTAIN.RESPONSES.${props.type.toUpperCase()}`);
const createResponse = responseDetails =>
store.dispatch('captainResponses/create', responseDetails);
const handleSubmit = async updatedResponse => {
try {
if (props.type === 'edit') {
await updateResponse(updatedResponse);
} else {
await createResponse(updatedResponse);
}
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage =
error?.response?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleClose = () => {
emit('close');
};
const handleCancel = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.RESPONSES.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
>
<ResponseForm
ref="responseForm"
:mode="type"
:response="selectedResponse"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer />
</Dialog>
</template>

View File

@@ -0,0 +1,161 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
response: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainResponses/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
};
const initialState = {
question: '',
answer: '',
assistantId: null,
};
const state = reactive({ ...initialState });
const validationRules = {
question: { required, minLength: minLength(1) },
answer: { required, minLength: minLength(1) },
assistantId: { required },
};
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
}))
);
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.RESPONSES.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
question: getErrorMessage('question', 'QUESTION'),
answer: getErrorMessage('answer', 'ANSWER'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
}));
const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => ({
question: state.question,
answer: state.answer,
assistant_id: state.assistantId,
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) {
return;
}
emit('submit', prepareDocumentDetails());
};
const updateStateFromResponse = response => {
if (!response) return;
const { question, answer, assistant } = response;
Object.assign(state, {
question,
answer,
assistantId: assistant.id,
});
};
watch(
() => props.response,
newResponse => {
if (props.mode === 'edit' && newResponse) {
updateStateFromResponse(newResponse);
}
},
{ immediate: true }
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.question"
:label="t('CAPTAIN.RESPONSES.FORM.QUESTION.LABEL')"
:placeholder="t('CAPTAIN.RESPONSES.FORM.QUESTION.PLACEHOLDER')"
:message="formErrors.question"
:message-type="formErrors.question ? 'error' : 'info'"
/>
<Editor
v-model="state.answer"
:label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
:placeholder="t('CAPTAIN.RESPONSES.FORM.ANSWER.PLACEHOLDER')"
:message="formErrors.answer"
:max-length="10000"
:message-type="formErrors.answer ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.RESPONSES.FORM.ASSISTANT.LABEL') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.RESPONSES.FORM.ASSISTANT.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
:message="formErrors.assistantId"
/>
</div>
<div class="flex items-center justify-between w-full gap-3">
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="t(`CAPTAIN.FORM.${mode.toUpperCase()}`)"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"
/>
</div>
</form>
</template>

View File

@@ -172,20 +172,20 @@ const menuItems = computed(() => {
icon: 'i-woot-captain',
label: t('SIDEBAR.CAPTAIN'),
children: [
{
name: 'Assistants',
label: t('SIDEBAR.CAPTAIN_ASSISTANTS'),
to: accountScopedRoute('captain_assistants_index'),
},
{
name: 'Documents',
label: 'Documents',
to: accountScopedRoute('captain', { page: 'documents' }),
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
to: accountScopedRoute('captain_documents_index'),
},
{
name: 'Responses',
label: 'Responses',
to: accountScopedRoute('captain', { page: 'responses' }),
},
{
name: 'Playground',
label: 'Playground',
to: accountScopedRoute('captain', { page: 'playground' }),
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
to: accountScopedRoute('captain_responses_index'),
},
],
},

View File

@@ -1,6 +1,6 @@
<script setup>
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import IntegrationsAPI from 'dashboard/api/integrations';
import ConversationAPI from 'dashboard/api/inbox/conversation';
import { useMapGetter } from 'dashboard/composables/store';
import { ref } from 'vue';
const props = defineProps({
@@ -28,16 +28,19 @@ const sendMessage = async message => {
isCaptainTyping.value = true;
try {
const { data } = await IntegrationsAPI.requestCaptainCopilot({
previous_history: messages.value
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
conversation_id: props.conversationId,
});
const { data } = await ConversationAPI.requestCopilot(
props.conversationId,
{
previous_history: messages.value
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
assistant_id: 16,
}
);
messages.value.push({
id: new Date().getTime(),
role: 'assistant',

View File

@@ -1,10 +1,11 @@
<script setup>
import { useStoreGetters } from 'dashboard/composables/store';
import { computed, ref } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from '../../../featureFlags';
const props = defineProps({
currentChat: {
@@ -15,13 +16,8 @@ const props = defineProps({
const emit = defineEmits(['toggleContactPanel']);
const getters = useStoreGetters();
const { t } = useI18n();
const captainIntegration = computed(() =>
getters['integrations/getIntegration'].value('captain', null)
);
const channelType = computed(() => props.currentChat?.meta?.channel || '');
const CONTACT_TABS_OPTIONS = [
@@ -45,10 +41,14 @@ const handleTabChange = selectedTab => {
tabItem => tabItem.value === selectedTab.value
);
};
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() => {
return captainIntegration.value && captainIntegration.value.enabled;
});
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
</script>
<template>

View File

@@ -307,6 +307,170 @@
"LOADER": "Captain is thinking",
"YOU": "You",
"USE": "Use this"
},
"FORM": {
"CANCEL": "Cancel",
"CREATE": "Create",
"EDIT": "Update"
},
"ASSISTANTS": {
"HEADER": "Assistants",
"ADD_NEW": "Create a new assistant",
"DELETE": {
"TITLE": "Are you sure to delete the assistant?",
"DESCRIPTION": "This action is permanent. Deleting this assistant will remove it from all connected inboxes and permanently erase all generated knowledge.",
"CONFIRM": "Yes, delete",
"SUCCESS_MESSAGE": "The assistant has been successfully deleted",
"ERROR_MESSAGE": "There was an error deleting the assistant, please try again."
},
"FORM_DESCRIPTION": "Fill out the details below to name your assistant, describe its purpose, and specify the product it will support.",
"CREATE": {
"TITLE": "Create an assistant",
"SUCCESS_MESSAGE": "The assistant has been successfully created",
"ERROR_MESSAGE": "There was an error creating the assistant, please try again."
},
"FORM": {
"NAME": {
"LABEL": "Assistant Name",
"PLACEHOLDER": "Enter a name for the assistant",
"ERROR": "Please provide a name for the assistant"
},
"DESCRIPTION": {
"LABEL": "Assistant Description",
"PLACEHOLDER": "Describe how and where this assistant will be used",
"ERROR": "A description is required"
},
"PRODUCT_NAME": {
"LABEL": "Product Name",
"PLACEHOLDER": "Enter the name of the product this assistant is designed for",
"ERROR": "The product name is required"
},
"FEATURES": {
"TITLE": "Features",
"ALLOW_CONVERSATION_FAQS": "Generate responses from resolved conversations",
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions."
}
},
"EDIT": {
"TITLE": "Update the assistant",
"SUCCESS_MESSAGE": "The assistant has been successfully updated",
"ERROR_MESSAGE": "There was an error updating the assistant, please try again."
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
"DELETE_ASSISTANT": "Delete Assistant",
"VIEW_CONNECTED_INBOXES": "View connected inboxes"
}
},
"DOCUMENTS": {
"HEADER": "Documents",
"ADD_NEW": "Create a new document",
"RELATED_RESPONSES": {
"TITLE": "Related Responses",
"DESCRIPTION": "These responses are generated directly from the document."
},
"FORM_DESCRIPTION": "Enter the URL of the document to add it as a knowledge source and choose the assistant to associate it with.",
"CREATE": {
"TITLE": "Add a document",
"SUCCESS_MESSAGE": "The document has been successfully created",
"ERROR_MESSAGE": "There was an error creating the document, please try again."
},
"FORM": {
"URL": {
"LABEL": "URL",
"PLACEHOLDER": "Enter the URL of the document",
"ERROR": "Please provide a valid URL for the document"
},
"ASSISTANT": {
"LABEL": "Assistant",
"PLACEHOLDER": "Select the assistant",
"ERROR": "The assistant field is required"
}
},
"DELETE": {
"TITLE": "Are you sure to delete the document?",
"DESCRIPTION": "This action is permanent. Deleting this document will permanently erase all generated knowledge.",
"CONFIRM": "Yes, delete",
"SUCCESS_MESSAGE": "The document has been successfully deleted",
"ERROR_MESSAGE": "There was an error deleting the document, please try again."
},
"OPTIONS": {
"VIEW_RELATED_RESPONSES": "View Related Responses",
"DELETE_DOCUMENT": "Delete Document"
}
},
"RESPONSES": {
"HEADER": "Generated FAQs",
"ADD_NEW": "Create new FAQ",
"DELETE": {
"TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "",
"CONFIRM": "Yes, delete",
"SUCCESS_MESSAGE": "FAQ deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQ, please try again."
},
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
"CREATE": {
"TITLE": "Add an FAQ",
"SUCCESS_MESSAGE": "The response has been added successfully.",
"ERROR_MESSAGE": "An error occurred while adding the response. Please try again."
},
"FORM": {
"QUESTION": {
"LABEL": "Question",
"PLACEHOLDER": "Enter the question here",
"ERROR": "Please provide a valid question."
},
"ANSWER": {
"LABEL": "Answer",
"PLACEHOLDER": "Enter the answer here",
"ERROR": "Please provide a valid answer."
},
"ASSISTANT": {
"LABEL": "Assistant",
"PLACEHOLDER": "Select an assistant",
"ERROR": "Please select an assistant."
}
},
"EDIT": {
"TITLE": "Update the FAQ",
"SUCCESS_MESSAGE": "The FAQ has been successfully updated",
"ERROR_MESSAGE": "There was an error updating the FAQ, please try again."
},
"OPTIONS": {
"EDIT_RESPONSE": "Edit FAQ",
"DELETE_RESPONSE": "Delete FAQ"
}
},
"INBOXES": {
"HEADER": "Connected Inboxes",
"ADD_NEW": "Connect a new inbox",
"OPTIONS" :{
"DISCONNECT": "Disconnect"
},
"DELETE": {
"TITLE": "Are you sure to disconnect the inbox?",
"DESCRIPTION": "",
"CONFIRM": "Yes, delete",
"SUCCESS_MESSAGE": "The inbox was successfully disconnected.",
"ERROR_MESSAGE": "There was an error disconnecting the inbox, please try again."
},
"FORM_DESCRIPTION": "Choose an inbox to connect with the assistant.",
"CREATE": {
"TITLE": "Connect an Inbox",
"SUCCESS_MESSAGE": "The inbox was successfully connected.",
"ERROR_MESSAGE": "An error occurred while connecting the inbox. Please try again."
},
"FORM": {
"INBOX": {
"LABEL": "Inbox",
"PLACEHOLDER": "Choose the inbox to deploy the assistant.",
"ERROR": "An inbox selection is required."
}
}
}
}
}

View File

@@ -263,6 +263,9 @@
"SETTINGS": "Settings",
"CONTACTS": "Contacts",
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES" : "FAQs",
"HOME": "Home",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",

View File

@@ -1,107 +0,0 @@
<script setup>
import { nextTick, watch, computed } from 'vue';
import IntegrationsAPI from 'dashboard/api/integrations';
import { useStoreGetters } from 'dashboard/composables/store';
import { makeRouter, setupApp } from '@chatwoot/captain';
const props = defineProps({
page: {
type: String,
required: true,
},
});
const getters = useStoreGetters();
const routeMap = {
documents: '/app/accounts/[account_id]/documents/',
playground: '/app/accounts/[account_id]/playground/',
responses: '/app/accounts/[account_id]/responses/',
};
const resolvedRoute = computed(() => routeMap[props.page]);
let router = null;
watch(
() => props.page,
() => {
if (router) {
router.push({ name: resolvedRoute.value });
}
},
{ immediate: true }
);
const buildApp = () => {
router = makeRouter();
setupApp('#captain', {
router,
fetchFn: async (source, options) => {
const parsedSource = new URL(source);
let path = parsedSource.pathname;
if (path === `/api/sessions/profile`) {
path = '/sessions/profile';
} else {
path = path.replace(/^\/api\/accounts\/\d+/, '');
}
// include search params
path = `${path}${parsedSource.search}`;
const response = await IntegrationsAPI.requestCaptain({
method: options.method ?? 'GET',
route: path,
body: options.body ? JSON.parse(options.body) : null,
});
return {
json: () => {
return response.data;
},
ok: response.status >= 200 && response.status < 300,
status: response.status,
headers: response.headers,
};
},
});
router.push({ name: resolvedRoute.value });
};
const captainIntegration = computed(() =>
getters['integrations/getIntegration'].value('captain', null)
);
watch(
() => captainIntegration.value,
(newValue, prevValue) => {
if (!prevValue && newValue) {
nextTick(() => buildApp());
}
},
{ immediate: true }
);
</script>
<template>
<div v-if="!captainIntegration">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}
</div>
<div
v-else-if="!captainIntegration.enabled"
class="flex-1 flex flex-col gap-2 items-center justify-center"
>
<div>{{ $t('INTEGRATION_SETTINGS.CAPTAIN.DISABLED') }}</div>
<router-link :to="{ name: 'settings_applications' }">
<woot-button class="clear link">
{{ $t('INTEGRATION_SETTINGS.CAPTAIN.CLICK_HERE_TO_CONFIGURE') }}
</woot-button>
</router-link>
</div>
<div v-else id="captain" class="w-full" />
</template>
<style>
@import '@chatwoot/captain/dist/style.css';
</style>

View File

@@ -0,0 +1,114 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const store = useStore();
const dialogType = ref('');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const assistants = useMapGetter('captainAssistants/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const selectedAssistant = ref(null);
const deleteAssistantDialog = ref(null);
const handleDelete = () => {
deleteAssistantDialog.value.dialogRef.open();
};
const createAssistantDialog = ref(null);
const handleCreate = () => {
dialogType.value = 'create';
nextTick(() => createAssistantDialog.value.dialogRef.open());
};
const handleEdit = () => {
dialogType.value = 'edit';
nextTick(() => createAssistantDialog.value.dialogRef.open());
};
const handleViewConnectedInboxes = () => {
router.push({
name: 'captain_assistants_inboxes_index',
params: { assistantId: selectedAssistant.value.id },
});
};
const handleAction = ({ action, id }) => {
selectedAssistant.value = assistants.value.find(
assistant => id === assistant.id
);
nextTick(() => {
if (action === 'delete') {
handleDelete();
}
if (action === 'edit') {
handleEdit();
}
if (action === 'viewConnectedInboxes') {
handleViewConnectedInboxes();
}
});
};
const handleCreateClose = () => {
dialogType.value = '';
selectedAssistant.value = null;
};
onMounted(() => store.dispatch('captainAssistants/get'));
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.HEADER')"
:button-label="$t('CAPTAIN.ASSISTANTS.ADD_NEW')"
:show-pagination-footer="false"
@click="handleCreate"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="assistants.length" class="flex flex-col gap-4">
<AssistantCard
v-for="assistant in assistants"
:id="assistant.id"
:key="assistant.id"
:name="assistant.name"
:description="assistant.description"
:updated-at="assistant.updated_at || assistant.created_at"
:created-at="assistant.created_at"
@action="handleAction"
/>
</div>
<div v-else>{{ 'No assistants found' }}</div>
<DeleteDialog
v-if="selectedAssistant"
ref="deleteAssistantDialog"
:entity="selectedAssistant"
type="Assistants"
/>
<CreateAssistantDialog
v-if="dialogType"
ref="createAssistantDialog"
:type="dialogType"
:selected-assistant="selectedAssistant"
@close="handleCreateClose"
/>
</PageLayout>
</template>

View File

@@ -0,0 +1,128 @@
<script setup>
import { computed, onBeforeMount, onMounted, ref, nextTick } from 'vue';
import {
useMapGetter,
useStore,
useStoreGetters,
} from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import BackButton from 'dashboard/components/widgets/BackButton.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConnectInboxDialog from 'dashboard/components-next/captain/pageComponents/inbox/ConnectInboxDialog.vue';
import InboxCard from 'dashboard/components-next/captain/assistant/InboxCard.vue';
const store = useStore();
const dialogType = ref('');
const route = useRoute();
const assistantUiFlags = useMapGetter('captainAssistants/getUIFlags');
const uiFlags = useMapGetter('captainInboxes/getUIFlags');
const isFetchingAssistant = computed(() => assistantUiFlags.value.fetchingItem);
const isFetching = computed(() => uiFlags.value.fetchingList);
const captainInboxes = useMapGetter('captainInboxes/getRecords');
const selectedInbox = ref(null);
const disconnectInboxDialog = ref(null);
const handleDelete = () => {
disconnectInboxDialog.value.dialogRef.open();
};
const connectInboxDialog = ref(null);
const handleCreate = () => {
dialogType.value = 'create';
nextTick(() => connectInboxDialog.value.dialogRef.open());
};
const handleAction = ({ action, id }) => {
selectedInbox.value = captainInboxes.value.find(inbox => id === inbox.id);
nextTick(() => {
if (action === 'delete') {
handleDelete();
}
});
};
const handleCreateClose = () => {
dialogType.value = '';
selectedInbox.value = null;
};
const getters = useStoreGetters();
const assistantId = Number(route.params.assistantId);
const assistant = computed(() =>
getters['captainAssistants/getRecord'].value(assistantId)
);
onBeforeMount(() => store.dispatch('captainAssistants/show', assistantId));
onMounted(() =>
store.dispatch('captainInboxes/get', {
assistantId: assistantId,
})
);
</script>
<template>
<div
v-if="isFetchingAssistant"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<PageLayout
v-else
:button-label="$t('CAPTAIN.INBOXES.ADD_NEW')"
:show-pagination-footer="false"
@click="handleCreate"
>
<template #headerTitle>
<div class="flex flex-row items-center gap-4">
<BackButton compact />
<span class="flex items-center gap-1 text-lg">
{{ assistant.name }}
<span class="i-lucide-chevron-right text-xl text-n-slate-10" />
{{ $t('CAPTAIN.INBOXES.HEADER') }}
</span>
</div>
</template>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="captainInboxes.length" class="flex flex-col gap-4">
<InboxCard
v-for="captainInbox in captainInboxes"
:id="captainInbox.id"
:key="captainInbox.id"
:inbox="captainInbox"
@action="handleAction"
/>
</div>
<div v-else>{{ 'There are no connected inboxes' }}</div>
<DeleteDialog
v-if="selectedInbox"
ref="disconnectInboxDialog"
:entity="selectedInbox"
:delete-payload="{
assistantId: assistantId,
inboxId: selectedInbox.id,
}"
type="Inboxes"
/>
<ConnectInboxDialog
v-if="dialogType"
ref="connectInboxDialog"
:assistant-id="assistantId"
:type="dialogType"
@close="handleCreateClose"
/>
</PageLayout>
</template>

View File

@@ -0,0 +1,47 @@
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { frontendURL } from '../../../helper/URLHelper';
import AssistantIndex from './assistants/Index.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue';
export const routes = [
{
path: frontendURL('accounts/:accountId/captain/assistants'),
component: AssistantIndex,
name: 'captain_assistants_index',
meta: {
featureFlag: FEATURE_FLAGS.CAPTAIN,
permissions: ['administrator', 'agent'],
},
},
{
path: frontendURL(
'accounts/:accountId/captain/assistants/:assistantId/inboxes'
),
component: AssistantInboxesIndex,
name: 'captain_assistants_inboxes_index',
meta: {
featureFlag: FEATURE_FLAGS.CAPTAIN,
permissions: ['administrator', 'agent'],
},
},
{
path: frontendURL('accounts/:accountId/captain/documents'),
component: DocumentsIndex,
name: 'captain_documents_index',
meta: {
featureFlag: FEATURE_FLAGS.CAPTAIN,
permissions: ['administrator', 'agent'],
},
},
{
path: frontendURL('accounts/:accountId/captain/responses'),
component: ResponsesIndex,
name: 'captain_responses_index',
meta: {
featureFlag: FEATURE_FLAGS.CAPTAIN,
permissions: ['administrator', 'agent'],
},
},
];

View File

@@ -0,0 +1,124 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
import CreateDocumentDialog from '../../../../components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
const store = useStore();
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
const documents = useMapGetter('captainDocuments/getRecords');
const assistants = useMapGetter('captainAssistants/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const documentsMeta = useMapGetter('captainDocuments/getMeta');
const selectedDocument = ref(null);
const deleteDocumentDialog = ref(null);
const handleDelete = () => {
deleteDocumentDialog.value.dialogRef.open();
};
const showRelatedResponses = ref(false);
const showCreateDialog = ref(false);
const createDocumentDialog = ref(null);
const relationQuestionDialog = ref(null);
const handleShowRelatedDocument = () => {
showRelatedResponses.value = true;
nextTick(() => relationQuestionDialog.value.dialogRef.open());
};
const handleCreateDocument = () => {
showCreateDialog.value = true;
nextTick(() => createDocumentDialog.value.dialogRef.open());
};
const handleRelatedResponseClose = () => {
showRelatedResponses.value = false;
};
const handleCreateDialogClose = () => {
showCreateDialog.value = false;
};
const handleAction = ({ action, id }) => {
selectedDocument.value = documents.value.find(
captainDocument => id === captainDocument.id
);
nextTick(() => {
if (action === 'delete') {
handleDelete();
} else if (action === 'viewRelatedQuestions') {
handleShowRelatedDocument();
}
});
};
const fetchDocuments = (page = 1) => {
store.dispatch('captainDocuments/get', { page });
};
const onPageChange = page => fetchDocuments(page);
onMounted(() => {
if (!assistants.value.length) {
store.dispatch('captainAssistants/get');
}
fetchDocuments();
});
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
:button-label="$t('CAPTAIN.DOCUMENTS.ADD_NEW')"
:total-count="documentsMeta.totalCount"
:current-page="documentsMeta.page"
:show-pagination-footer="!isFetching && !!documents.length"
@update:current-page="onPageChange"
@click="handleCreateDocument"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="documents.length" class="flex flex-col gap-4">
<DocumentCard
v-for="doc in documents"
:id="doc.id"
:key="doc.id"
:name="doc.name || doc.external_link"
:external-link="doc.external_link"
:assistant="doc.assistant"
:created-at="doc.created_at"
@action="handleAction"
/>
</div>
<div v-else>{{ 'No documents found' }}</div>
<RelatedResponses
v-if="showRelatedResponses"
ref="relationQuestionDialog"
:captain-document="selectedDocument"
@close="handleRelatedResponseClose"
/>
<CreateDocumentDialog
v-if="showCreateDialog"
ref="createDocumentDialog"
@close="handleCreateDialogClose"
/>
<DeleteDialog
v-if="selectedDocument"
ref="deleteDocumentDialog"
:entity="selectedDocument"
type="Documents"
/>
</PageLayout>
</template>

View File

@@ -0,0 +1,113 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
const store = useStore();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const selectedResponse = ref(null);
const deleteDialog = ref(null);
const dialogType = ref('');
const handleDelete = () => {
deleteDialog.value.dialogRef.open();
};
const createDialog = ref(null);
const handleCreate = () => {
dialogType.value = 'create';
nextTick(() => createDialog.value.dialogRef.open());
};
const handleEdit = () => {
dialogType.value = 'edit';
nextTick(() => createDialog.value.dialogRef.open());
};
const handleAction = ({ action, id }) => {
selectedResponse.value = responses.value.find(response => id === response.id);
nextTick(() => {
if (action === 'delete') {
handleDelete();
}
if (action === 'edit') {
handleEdit();
}
});
};
const handleCreateClose = () => {
dialogType.value = '';
selectedResponse.value = null;
};
const fetchResponses = (page = 1) => {
store.dispatch('captainResponses/get', { page });
};
const onPageChange = page => fetchResponses(page);
onMounted(() => {
store.dispatch('captainAssistants/get');
fetchResponses();
});
</script>
<template>
<PageLayout
:total-count="responseMeta.totalCount"
:current-page="responseMeta.page"
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
:show-pagination-footer="!isFetching && !!responses.length"
@update:current-page="onPageChange"
@click="handleCreate"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="responses.length" class="flex flex-col gap-4">
<ResponseCard
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
:answer="response.answer"
:assistant="response.assistant"
:created-at="response.created_at"
:updated-at="response.updated_at"
@action="handleAction"
/>
</div>
<div v-else>{{ 'No responses found' }}</div>
<DeleteDialog
v-if="selectedResponse"
ref="deleteDialog"
:entity="selectedResponse"
type="Responses"
/>
<CreateResponseDialog
v-if="dialogType"
ref="createDialog"
:type="dialogType"
:selected-response="selectedResponse"
@close="handleCreateClose"
/>
</PageLayout>
</template>

View File

@@ -7,11 +7,8 @@ import { routes as inboxRoutes } from './inbox/routes';
import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
import campaignsRoutes from './campaigns/campaigns.routes';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { routes as captainRoutes } from './captain/captain.routes';
import AppContainer from './Dashboard.vue';
import Captain from './Captain.vue';
import Suspended from './suspended/Index.vue';
export default {
@@ -20,16 +17,7 @@ export default {
path: frontendURL('accounts/:accountId'),
component: AppContainer,
children: [
{
path: frontendURL('accounts/:accountId/captain/:page'),
name: 'captain',
component: Captain,
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
},
props: true,
},
...captainRoutes,
...inboxRoutes,
...conversation.routes,
...settings.routes,

View File

@@ -0,0 +1,7 @@
import CaptainAssistantAPI from 'dashboard/api/captain/assistant';
import { createStore } from './storeFactory';
export default createStore({
name: 'CaptainAssistant',
API: CaptainAssistantAPI,
});

View File

@@ -0,0 +1,7 @@
import CaptainDocumentAPI from 'dashboard/api/captain/document';
import { createStore } from './storeFactory';
export default createStore({
name: 'CaptainDocument',
API: CaptainDocumentAPI,
});

View File

@@ -0,0 +1,22 @@
import CaptainInboxes from 'dashboard/api/captain/inboxes';
import { createStore } from './storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
name: 'CaptainInbox',
API: CaptainInboxes,
actions: mutations => ({
delete: async function remove({ commit }, { inboxId, assistantId }) {
commit(mutations.SET_UI_FLAG, { deletingItem: true });
try {
await CaptainInboxes.delete({ inboxId, assistantId });
commit(mutations.DELETE, inboxId);
commit(mutations.SET_UI_FLAG, { deletingItem: false });
return inboxId;
} catch (error) {
commit(mutations.SET_UI_FLAG, { deletingItem: false });
return throwErrorMessage(error);
}
},
}),
});

View File

@@ -0,0 +1,7 @@
import CaptainResponseAPI from 'dashboard/api/captain/response';
import { createStore } from './storeFactory';
export default createStore({
name: 'CaptainResponse',
API: CaptainResponseAPI,
});

View File

@@ -0,0 +1,140 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
export const generateMutationTypes = name => {
const capitalizedName = name.toUpperCase();
return {
SET_UI_FLAG: `SET_${capitalizedName}_UI_FLAG`,
SET: `SET_${capitalizedName}`,
ADD: `ADD_${capitalizedName}`,
EDIT: `EDIT_${capitalizedName}`,
DELETE: `DELETE_${capitalizedName}`,
SET_META: `SET_${capitalizedName}_META`,
};
};
export const createInitialState = () => ({
records: [],
meta: {},
uiFlags: {
fetchingList: false,
fetchingItem: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
});
export const createGetters = () => ({
getRecords: state => state.records.sort((r1, r2) => r2.id - r1.id),
getRecord: state => id =>
state.records.find(record => record.id === Number(id)) || {},
getUIFlags: state => state.uiFlags,
getMeta: state => state.meta,
});
// store/mutations.js
export const createMutations = mutationTypes => ({
[mutationTypes.SET_UI_FLAG](state, data) {
state.uiFlags = {
...state.uiFlags,
...data,
};
},
[mutationTypes.SET_META](state, meta) {
state.meta = {
totalCount: Number(meta.total_count),
page: Number(meta.page),
};
},
[mutationTypes.SET]: MutationHelpers.set,
[mutationTypes.ADD]: MutationHelpers.create,
[mutationTypes.EDIT]: MutationHelpers.update,
[mutationTypes.DELETE]: MutationHelpers.destroy,
});
// store/actions/crud.js
export const createCrudActions = (API, mutationTypes) => ({
async get({ commit }, params = {}) {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
try {
const response = await API.get(params);
commit(mutationTypes.SET, response.data.payload);
commit(mutationTypes.SET_META, response.data.meta);
return response.data.payload;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
}
},
async show({ commit }, id) {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
try {
const response = await API.show(id);
commit(mutationTypes.ADD, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
}
},
async create({ commit }, dataObj) {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
try {
const response = await API.create(dataObj);
commit(mutationTypes.ADD, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
}
},
async update({ commit }, { id, ...updateObj }) {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
try {
const response = await API.update(id, updateObj);
commit(mutationTypes.EDIT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
}
},
async delete({ commit }, id) {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
try {
await API.delete(id);
commit(mutationTypes.DELETE, id);
return id;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
}
},
});
export const createStore = options => {
const { name, API, actions } = options;
const mutationTypes = generateMutationTypes(name);
const customActions = actions ? actions(mutationTypes) : {};
return {
namespaced: true,
state: createInitialState(),
getters: createGetters(),
mutations: createMutations(mutationTypes),
actions: {
...createCrudActions(API, mutationTypes),
...customActions,
},
};
};

View File

@@ -45,7 +45,10 @@ import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
import draftMessages from './modules/draftMessages';
import SLAReports from './modules/SLAReports';
import captainAssistants from './captain/assistant';
import captainDocuments from './captain/document';
import captainResponses from './captain/response';
import captainInboxes from './captain/inboxes';
const plugins = [];
export default createStore({
@@ -95,6 +98,10 @@ export default createStore({
draftMessages,
sla,
slaReports: SLAReports,
captainAssistants,
captainDocuments,
captainResponses,
captainInboxes,
},
plugins,
});