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,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>