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

@@ -26,6 +26,12 @@ jobs:
override-ci-command: pnpm i
- run: node --version
- run: pnpm --version
- run:
name: Add PostgreSQL repository and update
command: |
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt-get update -y
- run:
name: Install System Dependencies
@@ -34,7 +40,9 @@ jobs:
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \
libpq-dev \
redis-server \
postgresql \
postgresql-common \
postgresql-16 \
postgresql-16-pgvector \
build-essential \
git \
curl \

View File

@@ -51,6 +51,7 @@ exclude_patterns:
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
- 'app/javascript/dashboard/store/captain/storeFactory.js'
- 'app/javascript/dashboard/i18n/index.js'
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'

View File

@@ -1,9 +1,3 @@
# #
# # This action will strip the enterprise folder
# # and run the spec.
# # This is set to run against every PR.
# #
name: Run Chatwoot CE spec
on:
push:
@@ -18,7 +12,7 @@ jobs:
runs-on: ubuntu-20.04
services:
postgres:
image: postgres:15.3
image: pgvector/pgvector:pg15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ''

View File

@@ -1,89 +0,0 @@
# #
# # This workflow will run specs related to response bot
# # This can only be activated in installations Where vector extension is available.
# #
name: Run Response Bot spec
on:
push:
branches:
- develop
- master
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-20.04
services:
postgres:
image: ankane/pgvector
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: pnpm/action-setup@v2
with:
version: 9.3.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: pnpm
run: pnpm install
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: Enable ResponseBotService in installation
run: RAILS_ENV=test bundle exec rails runner "Features::ResponseBotService.new.enable_in_installation"
# Run Response Bot specs
- name: Run backend tests
run: |
bundle exec rspec \
spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb \
spec/enterprise/services/enterprise/message_templates/response_bot_service_spec.rb \
spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 \
spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb \
--profile=10 \
--format documentation
- name: Upload rails log folder
uses: actions/upload-artifact@v4
if: always()
with:
name: rails-log-folder
path: log

View File

@@ -175,6 +175,8 @@ gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
gem 'ruby-openai'
### Gems required only in specific deployment environments ###
##############################################################

View File

@@ -231,6 +231,7 @@ GEM
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
event_stream_parser (1.0.0)
execjs (2.8.1)
facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7)
@@ -684,6 +685,10 @@ GEM
rubocop-rspec (2.21.0)
rubocop (~> 1.33)
rubocop-capybara (~> 2.17)
ruby-openai (7.3.1)
event_stream_parser (>= 0.3.0, < 2.0.0)
faraday (>= 1)
faraday-multipart (>= 1)
ruby-progressbar (1.13.0)
ruby-vips (2.1.4)
ffi (~> 1.12)
@@ -941,6 +946,7 @@ DEPENDENCIES
rubocop-performance
rubocop-rails
rubocop-rspec
ruby-openai
scout_apm
scss_lint
seed_dump

View File

@@ -1,78 +0,0 @@
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
before_action :hook
def proxy
request_url = build_request_url(request_path)
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
render plain: response.body, status: response.code
end
def copilot
request_url = build_request_url(build_request_path("/assistants/#{hook.settings['assistant_id']}/copilot"))
params = {
previous_messages: copilot_params[:previous_messages],
conversation_history: conversation_history,
message: copilot_params[:message]
}
response = HTTParty.send(:post, request_url, body: params.to_json, headers: headers)
render plain: response.body, status: response.code
end
private
def headers
{
'X-User-Email' => hook.settings['account_email'],
'X-User-Token' => hook.settings['access_token'],
'Content-Type' => 'application/json',
'Accept' => '*/*'
}
end
def build_request_path(route)
"api/accounts/#{hook.settings['account_id']}#{route}"
end
def request_path
request_route = with_leading_hash_on_route(params[:route])
return 'api/sessions/profile' if request_route == '/sessions/profile'
build_request_path(request_route)
end
def build_request_url(request_path)
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
URI.join(base_url, request_path).to_s
end
def hook
@hook ||= Current.account.hooks.find_by!(app_id: 'captain')
end
def request_method
method = permitted_params[:method].downcase
raise 'Invalid or missing HTTP method' unless %w[get post put patch delete options head].include?(method)
method
end
def with_leading_hash_on_route(request_route)
return '' if request_route.blank?
request_route.start_with?('/') ? request_route : "/#{request_route}"
end
def conversation_history
conversation = Current.account.conversations.find_by!(display_id: copilot_params[:conversation_id])
conversation.to_llm_text
end
def copilot_params
params.permit(:previous_messages, :conversation_id, :message)
end
def permitted_params
params.permit(:method, :route, body: {})
end
end

View File

@@ -7,9 +7,10 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def index
@articles = @portal.articles.published
@articles_count = @articles.count
search_articles
order_by_sort_param
@articles.page(list_params[:page]) if list_params[:page].present?
@articles = @articles.page(list_params[:page]) if list_params[:page].present?
end
def show; end
@@ -44,7 +45,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def list_params
params.permit(:query, :locale, :sort, :status)
params.permit(:query, :locale, :sort, :status, :page)
end
def permitted_params

View File

@@ -22,3 +22,5 @@ class AsyncDispatcher < BaseDispatcher
]
end
end
AsyncDispatcher.prepend_mod_with('AsyncDispatcher')

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,7 +28,9 @@ const sendMessage = async message => {
isCaptainTyping.value = true;
try {
const { data } = await IntegrationsAPI.requestCaptainCopilot({
const { data } = await ConversationAPI.requestCopilot(
props.conversationId,
{
previous_history: messages.value
.map(m => ({
role: m.role,
@@ -36,8 +38,9 @@ const sendMessage = async message => {
}))
.slice(0, -1),
message,
conversation_id: props.conversationId,
});
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,
});

View File

@@ -9,8 +9,6 @@ class HookJob < ApplicationJob
process_slack_integration(hook, event_name, event_data)
when 'dialogflow'
process_dialogflow_integration(hook, event_name, event_data)
when 'captain'
process_captain_integration(hook, event_name, event_data)
when 'google_translate'
google_translate_integration(hook, event_name, event_data)
end
@@ -37,12 +35,6 @@ class HookJob < ApplicationJob
Integrations::Dialogflow::ProcessorService.new(event_name: event_name, hook: hook, event_data: event_data).perform
end
def process_captain_integration(hook, event_name, event_data)
return unless ['message.created'].include?(event_name)
Integrations::Captain::ProcessorService.new(event_name: event_data, hook: hook, event_data: event_data).perform
end
def google_translate_integration(hook, event_name, event_data)
return unless ['message.created'].include?(event_name)

View File

@@ -42,6 +42,7 @@ class Contact < ApplicationRecord
include Avatarable
include AvailabilityStatusable
include Labelable
include LlmFormattable
validates :account_id, presence: true
validates :email, allow_blank: true, uniqueness: { scope: [:account_id], case_sensitive: false },

View File

@@ -130,15 +130,7 @@ class Inbox < ApplicationRecord
def active_bot?
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
status: 'enabled').count.positive? || captain_enabled?
end
def captain_enabled?
captain_hook = account.hooks.where(
app_id: %w[captain], status: 'enabled'
).first
captain_hook.present? && captain_hook.settings['inbox_ids'].split(',').include?(id.to_s)
status: 'enabled').count.positive?
end
def inbox_type

View File

@@ -40,8 +40,6 @@ class Integrations::App
ENV['SLACK_CLIENT_SECRET'].present?
when 'linear'
account.feature_enabled?('linear_integration')
when 'captain'
account.feature_enabled?('captain_integration') && InstallationConfig.find_by(name: 'CAPTAIN_APP_URL').present?
else
true
end

View File

@@ -18,7 +18,6 @@ class Integrations::Hook < ApplicationRecord
include Reauthorizable
attr_readonly :app_id, :account_id, :inbox_id, :hook_type
before_validation :ensure_captain_config_present, on: :create
before_validation :ensure_hook_type
validates :account_id, presence: true
@@ -64,30 +63,6 @@ class Integrations::Hook < ApplicationRecord
private
def ensure_captain_config_present
return if app_id != 'captain'
# Already configured, skip this
return if settings['access_token'].present?
ensure_captain_is_enabled
fetch_and_set_captain_settings
end
def ensure_captain_is_enabled
raise 'Captain is not enabled' unless Integrations::App.find(id: 'captain').active?(account)
end
def fetch_and_set_captain_settings
captain_response = ChatwootHub.get_captain_settings(account)
raise "Failed to get captain settings: #{captain_response.body}" unless captain_response.success?
captain_settings = JSON.parse(captain_response.body)
settings['account_email'] = captain_settings['account_email']
settings['account_id'] = captain_settings['captain_account_id'].to_s
settings['access_token'] = captain_settings['access_token']
settings['assistant_id'] = captain_settings['assistant_id'].to_s
end
def ensure_hook_type
self.hook_type = app.params[:hook_type] if app.present?
end

View File

@@ -21,11 +21,10 @@ class Note < ApplicationRecord
validates :content, presence: true
validates :account_id, presence: true
validates :contact_id, presence: true
validates :user_id, presence: true
belongs_to :account
belongs_to :contact
belongs_to :user
belongs_to :user, optional: true
scope :latest, -> { order(created_at: :desc) }

View File

@@ -38,10 +38,6 @@ class InboxPolicy < ApplicationPolicy
@account_user.administrator?
end
def response_sources?
@account_user.administrator?
end
def create?
@account_user.administrator?
end

View File

@@ -0,0 +1,35 @@
class LlmFormatter::ContactLlmFormatter < LlmFormatter::DefaultLlmFormatter
def format
sections = []
sections << "Contact ID: ##{@record.id}"
sections << 'Contact Attributes:'
sections << build_attributes
sections << 'Contact Notes:'
sections << if @record.notes.any?
build_notes
else
'No notes for this contact'
end
sections.join("\n")
end
private
def build_notes
@record.notes.all.map { |note| " - #{note.content}" }.join("\n")
end
def build_attributes
attributes = []
attributes << "Name: #{@record.name}"
attributes << "Email: #{@record.email}"
attributes << "Phone: #{@record.phone_number}"
attributes << "Location: #{@record.location}"
attributes << "Country Code: #{@record.country_code}"
@record.account.custom_attribute_definitions.with_attribute_model('contact_attribute').each do |attribute|
attributes << "#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}"
end
attributes.join("\n")
end
end

View File

@@ -122,6 +122,11 @@
<path d="M18.3 10.2H19.2C19.4387 10.2 19.6676 10.2948 19.8364 10.4636C20.0052 10.6324 20.1 10.8613 20.1 11.1V20.1C20.1 20.3387 20.0052 20.5676 19.8364 20.7364C19.6676 20.9052 19.4387 21 19.2 21H4.80002C4.56133 21 4.33241 20.9052 4.16363 20.7364C3.99485 20.5676 3.90002 20.3387 3.90002 20.1V11.1C3.90002 10.8613 3.99485 10.6324 4.16363 10.4636C4.33241 10.2948 4.56133 10.2 4.80002 10.2H5.70002V9.3C5.70002 8.47267 5.86298 7.65345 6.17958 6.88909C6.49619 6.12474 6.96024 5.43024 7.54525 4.84523C8.13026 4.26022 8.82477 3.79616 9.58912 3.47956C10.3535 3.16295 11.1727 3 12 3C12.8274 3 13.6466 3.16295 14.4109 3.47956C15.1753 3.79616 15.8698 4.26022 16.4548 4.84523C17.0398 5.43024 17.5039 6.12474 17.8205 6.88909C18.1371 7.65345 18.3 8.47267 18.3 9.3V10.2ZM5.70002 12V19.2H18.3V12H5.70002ZM11.1 13.8H12.9V17.4H11.1V13.8ZM16.5 10.2V9.3C16.5 8.10653 16.0259 6.96193 15.182 6.11802C14.3381 5.27411 13.1935 4.8 12 4.8C10.8066 4.8 9.66196 5.27411 8.81804 6.11802C7.97413 6.96193 7.50002 8.10653 7.50002 9.3V10.2H16.5Z" fill="currentColor"/>
</symbol>
<symbol id="icon-captain" viewBox="0 0 24 24">
<path d="M7.02051 9.50216C7.02051 9.01881 7.41237 8.62695 7.89571 8.62695C8.37909 8.62695 8.77091 9.01881 8.77091 9.50216V11.5248C8.77091 12.0082 8.37909 12.4 7.89571 12.4C7.41237 12.4 7.02051 12.0082 7.02051 11.5248V9.50216Z" fill="currentColor"/>
<path d="M9.82117 9.50216C9.82117 9.01881 10.213 8.62695 10.6964 8.62695C11.1798 8.62695 11.5716 9.01881 11.5716 9.50216V11.5248C11.5716 12.0082 11.1798 12.4 10.6964 12.4C10.213 12.4 9.82117 12.0082 9.82117 11.5248V9.50216Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.6162 6.22553C13.357 5.76568 10.7003 5.74756 7.36793 6.22351C6.5256 6.34382 5.96827 6.42512 5.54331 6.54928C5.14927 6.66446 4.92518 6.80177 4.73984 7.00894C4.35603 7.43786 4.30542 7.923 4.25311 9.61502C4.20172 11.2779 4.30356 12.7682 4.49645 14.445C4.59922 15.3386 4.66994 15.9416 4.78286 16.4001C4.88998 16.835 5.01955 17.0688 5.19961 17.2478C5.38111 17.4282 5.61372 17.5555 6.04062 17.6588C6.49219 17.768 7.08468 17.834 7.96525 17.9301C10.8131 18.2408 12.9449 18.2392 15.8034 17.9317C16.6946 17.8359 17.2968 17.7698 17.7551 17.6613C18.1905 17.5582 18.4247 17.4319 18.6036 17.2566C18.7789 17.085 18.9128 16.8483 19.0289 16.3917C19.15 15.9154 19.2325 15.2851 19.3518 14.3592C19.5646 12.7072 19.7179 11.2564 19.7238 9.66706C19.7302 7.96781 19.6955 7.48431 19.3073 7.03586C19.1198 6.81923 18.8919 6.67683 18.4889 6.55823C18.0537 6.43014 17.4813 6.3476 16.6162 6.22553ZM7.16446 4.79872C10.6358 4.30293 13.4288 4.32234 16.8172 4.80043L16.8668 4.8074C17.6691 4.92055 18.3469 5.01616 18.8952 5.17755C19.487 5.35169 19.9819 5.61608 20.3955 6.09398C21.1758 6.9954 21.1707 8.06494 21.1639 9.48592C21.1636 9.54745 21.1633 9.60961 21.1631 9.67247C21.1568 11.3529 20.9942 12.8746 20.7792 14.5431L20.7735 14.5873C20.6614 15.458 20.5689 16.1755 20.4237 16.7465C20.2709 17.3469 20.0427 17.8617 19.6106 18.2849C19.1823 18.7043 18.6761 18.9223 18.0866 19.0618C17.5299 19.1936 16.8372 19.2681 16.0022 19.3579L15.9573 19.3627C12.9972 19.6811 10.7615 19.6829 7.80915 19.3608L7.7636 19.3559C6.94029 19.2661 6.25475 19.1913 5.70225 19.0576C5.11489 18.9155 4.61209 18.6932 4.18492 18.2685C3.75628 17.8424 3.53115 17.336 3.38542 16.7443C3.24797 16.1863 3.16807 15.4915 3.07185 14.6548L3.06664 14.6095C2.86882 12.8898 2.76033 11.3256 2.81459 9.57052C2.81656 9.50684 2.81844 9.44387 2.8203 9.38159C2.86249 7.97127 2.89413 6.91333 3.66729 6.04925C4.07752 5.5908 4.56238 5.33656 5.13959 5.16786C5.6749 5.01141 6.33496 4.91716 7.11626 4.80561C7.13225 4.80331 7.14832 4.80102 7.16446 4.79872Z" fill="currentColor"/>
</symbol>
<symbol id="icon-up-caret" viewBox="0 0 48 48">
<path d="M2.988 33.02c-1.66 0-1.943-.81-.618-1.824l20-15.28c.878-.672 2.31-.67 3.188 0l20.075 15.288c1.316 1.003 1.048 1.816-.62 1.816H2.987z" />

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -40,12 +40,6 @@ as defined by the routes in the `admin/` namespace
}
%>
<% end %>
<hr class="border-slate-100 border-t my-5 mx-4"/>
<% if InstallationConfig.find_by(name: 'DEPLOYMENT_ENV')&.value == 'cloud' || Rails.env.development? %>
<%= render partial: "nav_item", locals: { icon: 'icon-folder-3-line', url: super_admin_response_sources_url, label: 'Sources' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-draft-line', url: super_admin_response_documents_url, label: 'Documents' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-reply-line', url: super_admin_responses_url, label: 'Responses' } %>
<% end %>
</ul>
</div>
<div>

View File

@@ -40,6 +40,7 @@ module Chatwoot
config.eager_load_paths << Rails.root.join('lib')
config.eager_load_paths << Rails.root.join('enterprise/lib')
config.eager_load_paths << Rails.root.join('enterprise/listeners')
# rubocop:disable Rails/FilePath
config.eager_load_paths += Dir["#{Rails.root}/enterprise/app/**"]
# rubocop:enable Rails/FilePath

View File

@@ -17,7 +17,7 @@ module ActiveRecord
extensions = @connection.extensions
return unless extensions.any?
stream.puts ' # These are extensions that must be enabled in order to support this database'
stream.puts ' # These extensions should be enabled to support this database'
extensions.sort.each do |extension|
stream.puts " enable_extension #{extension.inspect}" unless ignore_extentions.include?(extension)
end
@@ -27,11 +27,3 @@ module ActiveRecord
end
end
end
## Extentions / Tables to be ignored
ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaDumper.ignore_extentions << 'vector'
ActiveRecord::SchemaDumper.ignore_tables << 'responses'
ActiveRecord::SchemaDumper.ignore_tables << 'response_sources'
ActiveRecord::SchemaDumper.ignore_tables << 'response_documents'
ActiveRecord::SchemaDumper.ignore_tables << 'inbox_response_sources'
ActiveRecord::SchemaDumper.ignore_tables << 'article_embeddings'

View File

@@ -7,12 +7,15 @@ Sidekiq.configure_client do |config|
end
Sidekiq.configure_server do |config|
config.logger.formatter = Sidekiq::Logger::Formatters::JSON.new
config.redis = Redis::Config.app
# skip the default start stop logging
if Rails.env.production?
config.logger.formatter = Sidekiq::Logger::Formatters::JSON.new
config[:skip_default_job_logging] = true
config.logger.level = Logger.const_get(ENV.fetch('LOG_LEVEL', 'info').upcase.to_s)
end
end
# https://github.com/ondrejbartas/sidekiq-cron
Rails.application.reloader.to_prepare do

View File

@@ -133,6 +133,13 @@
locked: false
# End of Microsoft Email Channel Config
# MARK: Captain Config
- name: CAPTAIN_OPEN_AI_API_KEY
display_title: 'OpenAI API Key'
description: 'The OpenAI API key for the Captain AI service'
locked: false
# End of Captain Config
# ------- Chatwoot Internal Config for Cloud ----#
- name: CHATWOOT_INBOX_TOKEN
value:
@@ -222,14 +229,3 @@
locked: false
description: 'Contents on your firebase credentials json file'
## ------ End of Configs added for FCM v1 notifications ------ ##
## ----- Captain Configs ----- ##
- name: CAPTAIN_API_URL
value:
display_title: 'Captain API URL'
description: 'The API URL for Captain'
- name: CAPTAIN_APP_URL
value:
display_title: 'Captain App URL'
description: 'The App URL for Captain'
## ----- End of Captain Configs ----- ##

View File

@@ -8,34 +8,6 @@
# settings_json_schema: the json schema used to validate the settings hash (https://json-schema.org/)
# settings_form_schema: the formulate schema used in frontend to render settings form (https://vueformulate.com/)
########################################################
captain:
id: captain
logo: captain.png
i18n_key: captain
action: /captain
hook_type: account
allow_multiple_hooks: false
settings_json_schema: {
"type": "object",
"properties": {
"access_token": { "type": "string" },
"account_id": { "type": "string" },
"account_email": { "type": "string" },
"assistant_id": { "type": "string" },
"inbox_ids": { "type": "strings" },
},
"required": ["access_token", "account_id", "account_email", "assistant_id"],
"additionalProperties": false,
}
settings_form_schema: [
{
"label": "Inbox Ids",
"type": "text",
"name": "inbox_ids",
"validation": "",
},
]
visible_properties: []
webhooks:
id: webhook
logo: webhooks.png
@@ -56,27 +28,30 @@ openai:
action: /openai
hook_type: account
allow_multiple_hooks: false
settings_json_schema: {
"type": "object",
"properties": {
"api_key": { "type": "string" },
"label_suggestion": { "type": "boolean" },
settings_json_schema:
{
'type': 'object',
'properties':
{
'api_key': { 'type': 'string' },
'label_suggestion': { 'type': 'boolean' },
},
"required": ["api_key"],
"additionalProperties": false,
'required': ['api_key'],
'additionalProperties': false,
}
settings_form_schema: [
settings_form_schema:
[
{
"label": "API Key",
"type": "text",
"name": "api_key",
"validation": "required",
'label': 'API Key',
'type': 'text',
'name': 'api_key',
'validation': 'required',
},
{
"label": "Show label suggestions",
"type": "checkbox",
"name": "label_suggestion",
"validation": "",
'label': 'Show label suggestions',
'type': 'checkbox',
'name': 'label_suggestion',
'validation': '',
},
]
visible_properties: ['api_key', 'label_suggestion']
@@ -87,20 +62,20 @@ linear:
action: /linear
hook_type: account
allow_multiple_hooks: false
settings_json_schema: {
"type": "object",
"properties": {
"api_key": { "type": "string" },
},
"required": ["api_key"],
"additionalProperties": false,
}
settings_form_schema: [
settings_json_schema:
{
"label": "API Key",
"type": "text",
"name": "api_key",
"validation": "required",
'type': 'object',
'properties': { 'api_key': { 'type': 'string' } },
'required': ['api_key'],
'additionalProperties': false,
}
settings_form_schema:
[
{
'label': 'API Key',
'type': 'text',
'name': 'api_key',
'validation': 'required',
},
]
visible_properties: []
@@ -118,34 +93,35 @@ dialogflow:
action: /dialogflow
hook_type: inbox
allow_multiple_hooks: true
settings_json_schema: {
"type": "object",
"properties": {
"project_id": { "type": "string" },
"credentials": { "type": "object" }
},
"required": ["project_id", "credentials"],
"additionalProperties": false
}
settings_form_schema: [
settings_json_schema:
{
"label": "Dialogflow Project ID",
"type": "text",
"name": "project_id",
"validation": "required",
"validationName": 'Project Id',
'type': 'object',
'properties':
{
'project_id': { 'type': 'string' },
'credentials': { 'type': 'object' },
},
'required': ['project_id', 'credentials'],
'additionalProperties': false,
}
settings_form_schema:
[
{
'label': 'Dialogflow Project ID',
'type': 'text',
'name': 'project_id',
'validation': 'required',
'validationName': 'Project Id',
},
{
"label": "Dialogflow Project Key File",
"type": "textarea",
"name": "credentials",
"validation": "required|JSON",
"validationName": 'Credentials',
"validation-messages": {
"JSON": "Invalid JSON",
"required": "Credentials is required"
}
}
'label': 'Dialogflow Project Key File',
'type': 'textarea',
'name': 'credentials',
'validation': 'required|JSON',
'validationName': 'Credentials',
'validation-messages':
{ 'JSON': 'Invalid JSON', 'required': 'Credentials is required' },
},
]
visible_properties: ['project_id']
google_translate:
@@ -155,33 +131,34 @@ google_translate:
action: /google_translate
hook_type: account
allow_multiple_hooks: false
settings_json_schema: {
"type": "object",
"properties": {
"project_id": { "type": "string" },
"credentials": { "type": "object" },
settings_json_schema:
{
'type': 'object',
'properties':
{
'project_id': { 'type': 'string' },
'credentials': { 'type': 'object' },
},
"required": ["project_id", "credentials"],
"additionalProperties": false,
'required': ['project_id', 'credentials'],
'additionalProperties': false,
}
settings_form_schema: [
settings_form_schema:
[
{
"label": "Google Cloud Project ID",
"type": "text",
"name": "project_id",
"validation": "required",
"validationName": "Project Id",
'label': 'Google Cloud Project ID',
'type': 'text',
'name': 'project_id',
'validation': 'required',
'validationName': 'Project Id',
},
{
"label": "Google Cloud Project Key File",
"type": "textarea",
"name": "credentials",
"validation": "required|JSON",
"validationName": "Credentials",
"validation-messages": {
"JSON": "Invalid JSON",
"required": "Credentials is required"
},
'label': 'Google Cloud Project Key File',
'type': 'textarea',
'name': 'credentials',
'validation': 'required|JSON',
'validationName': 'Credentials',
'validation-messages':
{ 'JSON': 'Invalid JSON', 'required': 'Credentials is required' },
},
]
visible_properties: ['project_id']
@@ -192,27 +169,30 @@ dyte:
action: /dyte
hook_type: account
allow_multiple_hooks: false
settings_json_schema: {
"type": "object",
"properties": {
"api_key": { "type": "string" },
"organization_id": { "type": "string" },
settings_json_schema:
{
'type': 'object',
'properties':
{
'api_key': { 'type': 'string' },
'organization_id': { 'type': 'string' },
},
"required": ["api_key", "organization_id"],
"additionalProperties": false,
'required': ['api_key', 'organization_id'],
'additionalProperties': false,
}
settings_form_schema: [
settings_form_schema:
[
{
"label": "Organization ID",
"type": "text",
"name": "organization_id",
"validation": "required",
'label': 'Organization ID',
'type': 'text',
'name': 'organization_id',
'validation': 'required',
},
{
"label": "API Key",
"type": "text",
"name": "api_key",
"validation": "required",
'label': 'API Key',
'type': 'text',
'name': 'api_key',
'validation': 'required',
},
]
visible_properties: ["organization_id"]
visible_properties: ['organization_id']

View File

@@ -30,7 +30,7 @@
# available at https://guides.rubyonrails.org/i18n.html.
en:
hello: "Hello world"
hello: 'Hello world'
messages:
reset_password_success: Woot! Request for password reset is successful. Check your mail for instructions.
reset_password_failure: Uh ho! We could not find any user with the specified email.
@@ -45,7 +45,7 @@ en:
disposable_email: We do not allow disposable emails
blocked_domain: This domain is not allowed. If you believe this is a mistake, please contact support.
invalid_email: You have entered an invalid email
email_already_exists: "You have already signed up for an account with %{email}"
email_already_exists: 'You have already signed up for an account with %{email}'
invalid_params: 'Invalid, please check the signup paramters and try again'
failed: Signup failed
data_import:
@@ -64,9 +64,9 @@ en:
locale:
unique: should be unique in the category and portal
dyte:
invalid_message_type: "Invalid message type. Action not permitted"
invalid_message_type: 'Invalid message type. Action not permitted'
slack:
invalid_channel_id: "Invalid slack channel. Please try again"
invalid_channel_id: 'Invalid slack channel. Please try again'
inboxes:
imap:
socket_error: Please check the network connection, IMAP address and try again.
@@ -135,103 +135,102 @@ en:
notifications:
notification_title:
conversation_creation: "A conversation (#%{display_id}) has been created in %{inbox_name}"
conversation_assignment: "A conversation (#%{display_id}) has been assigned to you"
assigned_conversation_new_message: "A new message is created in conversation (#%{display_id})"
conversation_mention: "You have been mentioned in conversation (#%{display_id})"
sla_missed_first_response: "SLA target first response missed for conversation (#%{display_id})"
sla_missed_next_response: "SLA target next response missed for conversation (#%{display_id})"
sla_missed_resolution: "SLA target resolution missed for conversation (#%{display_id})"
attachment: "Attachment"
no_content: "No content"
conversation_creation: 'A conversation (#%{display_id}) has been created in %{inbox_name}'
conversation_assignment: 'A conversation (#%{display_id}) has been assigned to you'
assigned_conversation_new_message: 'A new message is created in conversation (#%{display_id})'
conversation_mention: 'You have been mentioned in conversation (#%{display_id})'
sla_missed_first_response: 'SLA target first response missed for conversation (#%{display_id})'
sla_missed_next_response: 'SLA target next response missed for conversation (#%{display_id})'
sla_missed_resolution: 'SLA target resolution missed for conversation (#%{display_id})'
attachment: 'Attachment'
no_content: 'No content'
conversations:
messages:
instagram_story_content: "%{story_sender} mentioned you in the story: "
instagram_story_content: '%{story_sender} mentioned you in the story: '
instagram_deleted_story_content: This story is no longer available.
deleted: This message was deleted
delivery_status:
error_code: "Error code: %{error_code}"
error_code: 'Error code: %{error_code}'
activity:
status:
resolved: "Conversation was marked resolved by %{user_name}"
contact_resolved: "Conversation was resolved by %{contact_name}"
open: "Conversation was reopened by %{user_name}"
pending: "Conversation was marked as pending by %{user_name}"
snoozed: "Conversation was snoozed by %{user_name}"
auto_resolved: "Conversation was marked resolved by system due to %{duration} days of inactivity"
resolved: 'Conversation was marked resolved by %{user_name}'
contact_resolved: 'Conversation was resolved by %{contact_name}'
open: 'Conversation was reopened by %{user_name}'
pending: 'Conversation was marked as pending by %{user_name}'
snoozed: 'Conversation was snoozed by %{user_name}'
auto_resolved: 'Conversation was marked resolved by system due to %{duration} days of inactivity'
system_auto_open: System reopened the conversation due to a new incoming message.
priority:
added: '%{user_name} set the priority to %{new_priority}'
updated: '%{user_name} changed the priority from %{old_priority} to %{new_priority}'
removed: '%{user_name} removed the priority'
assignee:
self_assigned: "%{user_name} self-assigned this conversation"
assigned: "Assigned to %{assignee_name} by %{user_name}"
removed: "Conversation unassigned by %{user_name}"
self_assigned: '%{user_name} self-assigned this conversation'
assigned: 'Assigned to %{assignee_name} by %{user_name}'
removed: 'Conversation unassigned by %{user_name}'
team:
assigned: "Assigned to %{team_name} by %{user_name}"
assigned_with_assignee: "Assigned to %{assignee_name} via %{team_name} by %{user_name}"
removed: "Unassigned from %{team_name} by %{user_name}"
assigned: 'Assigned to %{team_name} by %{user_name}'
assigned_with_assignee: 'Assigned to %{assignee_name} via %{team_name} by %{user_name}'
removed: 'Unassigned from %{team_name} by %{user_name}'
labels:
added: "%{user_name} added %{labels}"
removed: "%{user_name} removed %{labels}"
added: '%{user_name} added %{labels}'
removed: '%{user_name} removed %{labels}'
sla:
added: "%{user_name} added SLA policy %{sla_name}"
removed: "%{user_name} removed SLA policy %{sla_name}"
muted: "%{user_name} has muted the conversation"
unmuted: "%{user_name} has unmuted the conversation"
added: '%{user_name} added SLA policy %{sla_name}'
removed: '%{user_name} removed SLA policy %{sla_name}'
muted: '%{user_name} has muted the conversation'
unmuted: '%{user_name} has unmuted the conversation'
templates:
greeting_message_body: "%{account_name} typically replies in a few hours."
ways_to_reach_you_message_body: "Give the team a way to reach you."
email_input_box_message_body: "Get notified by email"
csat_input_message_body: "Please rate the conversation"
greeting_message_body: '%{account_name} typically replies in a few hours.'
ways_to_reach_you_message_body: 'Give the team a way to reach you.'
email_input_box_message_body: 'Get notified by email'
csat_input_message_body: 'Please rate the conversation'
reply:
email:
header:
from_with_name: "%{assignee_name} from %{inbox_name} <%{from_email}>"
reply_with_name: "%{assignee_name} from %{inbox_name} <reply+%{reply_email}>"
friendly_name: "%{sender_name} from %{business_name} <%{from_email}>"
professional_name: "%{business_name} <%{from_email}>"
from_with_name: '%{assignee_name} from %{inbox_name} <%{from_email}>'
reply_with_name: '%{assignee_name} from %{inbox_name} <reply+%{reply_email}>'
friendly_name: '%{sender_name} from %{business_name} <%{from_email}>'
professional_name: '%{business_name} <%{from_email}>'
channel_email:
header:
reply_with_name: "%{assignee_name} from %{inbox_name} <%{from_email}>"
reply_with_inbox_name: "%{inbox_name} <%{from_email}>"
email_subject: "New messages on this conversation"
transcript_subject: "Conversation Transcript"
reply_with_name: '%{assignee_name} from %{inbox_name} <%{from_email}>'
reply_with_inbox_name: '%{inbox_name} <%{from_email}>'
email_subject: 'New messages on this conversation'
transcript_subject: 'Conversation Transcript'
survey:
response: "Please rate this conversation, %{link}"
response: 'Please rate this conversation, %{link}'
contacts:
online:
delete: "%{contact_name} is Online, please try again later"
delete: '%{contact_name} is Online, please try again later'
integration_apps:
dashboard_apps:
name: "Dashboard Apps"
description: "Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents."
name: 'Dashboard Apps'
description: 'Dashboard Apps allow you to create and embed applications that display user information, orders, or payment history, providing more context to your customer support agents.'
dyte:
name: "Dyte"
description: "Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot."
meeting_name: "%{agent_name} has started a meeting"
name: 'Dyte'
description: 'Dyte is a product that integrates audio and video functionalities into your application. With this integration, your agents can start video/voice calls with your customers directly from Chatwoot.'
meeting_name: '%{agent_name} has started a meeting'
slack:
name: "Slack"
name: 'Slack'
description: "Integrate Chatwoot with Slack to keep your team in sync. This integration allows you to receive notifications for new conversations and respond to them directly within Slack's interface."
webhooks:
name: "Webhooks"
description: "Webhook events provide real-time updates about activities in your Chatwoot account. You can subscribe to your preferred events, and Chatwoot will send you HTTP callbacks with the updates."
name: 'Webhooks'
description: 'Webhook events provide real-time updates about activities in your Chatwoot account. You can subscribe to your preferred events, and Chatwoot will send you HTTP callbacks with the updates.'
dialogflow:
name: "Dialogflow"
description: "Build chatbots with Dialogflow and easily integrate them into your inbox. These bots can handle initial queries before transferring them to a customer service agent."
name: 'Dialogflow'
description: 'Build chatbots with Dialogflow and easily integrate them into your inbox. These bots can handle initial queries before transferring them to a customer service agent.'
google_translate:
name: "Google Translate"
name: 'Google Translate'
description: "Integrate Google Translate to help agents easily translate customer messages. This integration automatically detects the language and converts it to the agent's or admin's preferred language."
openai:
name: "OpenAI"
description: "Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification."
name: 'OpenAI'
description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification.'
linear:
name: "Linear"
description: "Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process."
name: 'Linear'
description: 'Create issues in Linear directly from your conversation window. Alternatively, link existing Linear issues for a more streamlined and efficient issue tracking process.'
captain:
name: "Captain"
description: "Captain is a native AI assistant built for your product and trained on your company's knowledge base. It responds like a human and resolves customer queries effectively. Configure it to your inboxes easily."
copilot_error: 'Please connect an assistant to this inbox to use copilot'
public_portal:
search:
search_placeholder: Search for article by title or body...
@@ -278,14 +277,14 @@ en:
button: Open conversation
time_units:
days:
one: "%{count} day"
other: "%{count} days"
one: '%{count} day'
other: '%{count} days'
hours:
one: "%{count} hour"
other: "%{count} hours"
one: '%{count} hour'
other: '%{count} hours'
minutes:
one: "%{count} minute"
other: "%{count} minutes"
one: '%{count} minute'
other: '%{count} minutes'
seconds:
one: "%{count} second"
other: "%{count} seconds"
one: '%{count} second'
other: '%{count} seconds'

View File

@@ -48,6 +48,13 @@ Rails.application.routes.draw do
resources :agents, only: [:index, :create, :update, :destroy] do
post :bulk_create, on: :collection
end
namespace :captain do
resources :assistants do
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
end
resources :documents, only: [:index, :show, :create, :destroy]
resources :assistant_responses
end
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
delete :avatar, on: :member
end
@@ -110,6 +117,7 @@ Rails.application.routes.draw do
post :unread
post :custom_attributes
get :attachments
post :copilot
end
end
@@ -158,7 +166,6 @@ Rails.application.routes.draw do
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
get :assignable_agents, on: :member
get :campaigns, on: :member
get :response_sources, on: :member
get :agent_bot, on: :member
post :set_agent_bot, on: :member
delete :avatar, on: :member
@@ -170,15 +177,6 @@ Rails.application.routes.draw do
end
end
resources :labels, only: [:index, :show, :create, :update, :destroy]
resources :response_sources, only: [:create] do
collection do
post :parse
end
member do
post :add_document
post :remove_document
end
end
resources :notifications, only: [:index, :update, :destroy] do
collection do
@@ -217,12 +215,6 @@ Rails.application.routes.draw do
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resources :apps, only: [:index, :show]
resource :captain, controller: 'captain', only: [] do
collection do
post :proxy
post :copilot
end
end
resources :hooks, only: [:show, :create, :update, :destroy] do
member do
post :process_event
@@ -364,6 +356,7 @@ Rails.application.routes.draw do
end
post 'webhooks/stripe', to: 'webhooks/stripe#process_payload'
post 'webhooks/firecrawl', to: 'webhooks/firecrawl#process_payload'
end
end
@@ -488,10 +481,6 @@ Rails.application.routes.draw do
end
resources :access_tokens, only: [:index, :show]
resources :response_sources, only: [:index, :show, :new, :create, :edit, :update, :destroy] do
get :chat, on: :member
post :chat, on: :member, action: :process_chat
end
resources :response_documents, only: [:index, :show, :new, :create, :edit, :update, :destroy]
resources :responses, only: [:index, :show, :new, :create, :edit, :update, :destroy]
resources :installation_configs, only: [:index, :new, :create, :show, :edit, :update]

View File

@@ -7,29 +7,28 @@ internal_check_new_versions_job:
cron: '0 12 */1 * *'
class: 'Internal::CheckNewVersionsJob'
queue: scheduled_jobs
# # executed At every 5th minute..
# trigger_scheduled_items_job:
# cron: '*/5 * * * *'
# class: 'TriggerScheduledItemsJob'
# queue: scheduled_jobs
# executed At every 5th minute..
trigger_scheduled_items_job:
cron: '*/5 * * * *'
class: 'TriggerScheduledItemsJob'
queue: scheduled_jobs
# # executed At every minute..
# trigger_imap_email_inboxes_job:
# cron: '*/1 * * * *'
# class: 'Inboxes::FetchImapEmailInboxesJob'
# queue: scheduled_jobs
# executed At every minute..
trigger_imap_email_inboxes_job:
cron: '*/1 * * * *'
class: 'Inboxes::FetchImapEmailInboxesJob'
queue: scheduled_jobs
# # executed daily at 2230 UTC
# # which is our lowest traffic time
# remove_stale_contact_inboxes_job.rb:
# cron: '30 22 * * *'
# class: 'Internal::RemoveStaleContactInboxesJob'
# queue: scheduled_jobs
# executed daily at 2230 UTC
# which is our lowest traffic time
remove_stale_contact_inboxes_job.rb:
cron: '30 22 * * *'
class: 'Internal::RemoveStaleContactInboxesJob'
queue: scheduled_jobs
# executed daily at 2230 UTC
# which is our lowest traffic time
remove_stale_redis_keys_job.rb:
cron: '30 22 * * *'
class: 'Internal::RemoveStaleRedisKeysJob'
queue: scheduled_jobs
# # executed daily at 2230 UTC
# # which is our lowest traffic time
# remove_stale_redis_keys_job.rb:
# cron: '30 22 * * *'
# class: 'Internal::RemoveStaleRedisKeysJob'
# queue: scheduled_jobs

View File

@@ -0,0 +1,90 @@
class CreateCaptainTables < ActiveRecord::Migration[7.0]
def up
# Post this migration, the 'vector' extension is mandatory to run the application.
# If the extension is not installed, the migration will raise an error.
setup_vector_extension
create_assistants
create_documents
create_assistant_responses
create_old_tables
end
def down
drop_table :captain_assistant_responses if table_exists?(:captain_assistant_responses)
drop_table :captain_documents if table_exists?(:captain_documents)
drop_table :captain_assistants if table_exists?(:captain_assistants)
drop_table :article_embeddings if table_exists?(:article_embeddings)
# We are not disabling the extension here because it might be
# used by other tables which are not part of this migration.
end
private
def setup_vector_extension
return if extension_enabled?('vector')
begin
enable_extension 'vector'
rescue ActiveRecord::StatementInvalid
raise StandardError, "Failed to enable 'vector' extension. Read more at https://chwt.app/v4/migration"
end
end
def create_assistants
create_table :captain_assistants do |t|
t.string :name, null: false
t.bigint :account_id, null: false
t.string :description
t.timestamps
end
add_index :captain_assistants, :account_id
add_index :captain_assistants, [:account_id, :name], unique: true
end
def create_documents
create_table :captain_documents do |t|
t.string :name, null: false
t.string :external_link, null: false
t.text :content
t.bigint :assistant_id, null: false
t.bigint :account_id, null: false
t.timestamps
end
add_index :captain_documents, :account_id
add_index :captain_documents, :assistant_id
add_index :captain_documents, [:assistant_id, :external_link], unique: true
end
def create_assistant_responses
create_table :captain_assistant_responses do |t|
t.string :question, null: false
t.text :answer, null: false
t.vector :embedding, limit: 1536
t.bigint :assistant_id, null: false
t.bigint :document_id
t.bigint :account_id, null: false
t.timestamps
end
add_index :captain_assistant_responses, :account_id
add_index :captain_assistant_responses, :assistant_id
add_index :captain_assistant_responses, :document_id
add_index :captain_assistant_responses, :embedding, using: :ivfflat, name: 'vector_idx_knowledge_entries_embedding', opclass: :vector_l2_ops
end
def create_old_tables
create_table :article_embeddings, if_not_exists: true do |t|
t.bigint :article_id, null: false
t.text :term, null: false
t.vector :embedding, limit: 1536
t.timestamps
end
add_index :article_embeddings, :embedding, if_not_exists: true, using: :ivfflat, opclass: :vector_l2_ops
end
end

View File

@@ -0,0 +1,10 @@
class RemoveRobinTables < ActiveRecord::Migration[7.0]
def change
# rubocop:disable Rails/ReversibleMigration
drop_table :responses if table_exists?(:responses)
drop_table :response_sources if table_exists?(:response_sources)
drop_table :response_documents if table_exists?(:response_documents)
drop_table :inbox_response_sources if table_exists?(:inbox_response_sources)
# rubocop:enable Rails/ReversibleMigration
end
end

View File

@@ -0,0 +1,6 @@
class AddStatusToCaptainDocuments < ActiveRecord::Migration[7.0]
def change
add_column :captain_documents, :status, :integer, null: false, default: 0
add_index :captain_documents, :status
end
end

View File

@@ -0,0 +1,5 @@
class RemoveNotNullFromCaptainDocuments < ActiveRecord::Migration[7.0]
def change
change_column_null :captain_documents, :name, true
end
end

View File

@@ -0,0 +1,5 @@
class AddConfigToCaptainAssistant < ActiveRecord::Migration[7.0]
def change
add_column :captain_assistants, :config, :jsonb, default: {}, null: false
end
end

View File

@@ -0,0 +1,11 @@
class CreateCaptainInbox < ActiveRecord::Migration[7.0]
def change
create_table :captain_inboxes do |t|
t.references :captain_assistant, null: false
t.references :inbox, null: false
t.timestamps
end
add_index :captain_inboxes, [:captain_assistant_id, :inbox_id], unique: true
end
end

View File

@@ -0,0 +1,5 @@
class RemoveIndexFromCaptainAssistants < ActiveRecord::Migration[7.0]
def change
remove_index :captain_assistants, [:account_id, :name], if_exists: true
end
end

View File

@@ -10,12 +10,13 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2024_12_17_041352) do
# These are extensions that must be enabled in order to support this database
ActiveRecord::Schema[7.0].define(version: 2025_01_08_211541) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
enable_extension "pgcrypto"
enable_extension "plpgsql"
enable_extension "vector"
create_table "access_tokens", force: :cascade do |t|
t.string "owner_type"
@@ -130,6 +131,15 @@ ActiveRecord::Schema[7.0].define(version: 2024_12_17_041352) do
t.index ["sla_policy_id"], name: "index_applied_slas_on_sla_policy_id"
end
create_table "article_embeddings", force: :cascade do |t|
t.bigint "article_id", null: false
t.text "term", null: false
t.vector "embedding", limit: 1536
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["embedding"], name: "index_article_embeddings_on_embedding", using: :ivfflat
end
create_table "articles", force: :cascade do |t|
t.integer "account_id", null: false
t.integer "portal_id", null: false
@@ -235,6 +245,56 @@ ActiveRecord::Schema[7.0].define(version: 2024_12_17_041352) do
t.datetime "updated_at", precision: nil, null: false
end
create_table "captain_assistant_responses", force: :cascade do |t|
t.string "question", null: false
t.text "answer", null: false
t.vector "embedding", limit: 1536
t.bigint "assistant_id", null: false
t.bigint "document_id"
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_captain_assistant_responses_on_account_id"
t.index ["assistant_id"], name: "index_captain_assistant_responses_on_assistant_id"
t.index ["document_id"], name: "index_captain_assistant_responses_on_document_id"
t.index ["embedding"], name: "vector_idx_knowledge_entries_embedding", using: :ivfflat
end
create_table "captain_assistants", force: :cascade do |t|
t.string "name", null: false
t.bigint "account_id", null: false
t.string "description"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "config", default: {}, null: false
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
end
create_table "captain_documents", force: :cascade do |t|
t.string "name"
t.string "external_link", null: false
t.text "content"
t.bigint "assistant_id", null: false
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "status", default: 0, null: false
t.index ["account_id"], name: "index_captain_documents_on_account_id"
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
t.index ["status"], name: "index_captain_documents_on_status"
end
create_table "captain_inboxes", force: :cascade do |t|
t.bigint "captain_assistant_id", null: false
t.bigint "inbox_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["captain_assistant_id", "inbox_id"], name: "index_captain_inboxes_on_captain_assistant_id_and_inbox_id", unique: true
t.index ["captain_assistant_id"], name: "index_captain_inboxes_on_captain_assistant_id"
t.index ["inbox_id"], name: "index_captain_inboxes_on_inbox_id"
end
create_table "categories", force: :cascade do |t|
t.integer "account_id", null: false
t.integer "portal_id", null: false

View File

@@ -0,0 +1,68 @@
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
before_action :set_assistant, only: [:create]
before_action :set_responses, except: [:create]
before_action :set_response, only: [:show, :update, :destroy]
RESULTS_PER_PAGE = 25
def index
base_query = @responses
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
base_query = base_query.where(document_id: permitted_params[:document_id]) if permitted_params[:document_id].present?
@responses_count = base_query.count
@responses = base_query.page(@current_page).per(RESULTS_PER_PAGE)
end
def show; end
def create
@response = Current.account.captain_assistant_responses.new(response_params)
@response.save!
end
def update
@response.update!(response_params)
end
def destroy
@response.destroy
head :no_content
end
private
def set_assistant
@assistant = Current.account.captain_assistants.find_by(id: params[:assistant_id])
end
def set_responses
@responses = Current.account.captain_assistant_responses.includes(:assistant, :document).ordered
end
def set_response
@response = @responses.find(permitted_params[:id])
end
def set_current_page
@current_page = permitted_params[:page] || 1
end
def permitted_params
params.permit(:id, :assistant_id, :page, :document_id, :account_id)
end
def response_params
params.require(:assistant_response).permit(
:question,
:answer,
:document_id,
:assistant_id
)
end
end

View File

@@ -0,0 +1,39 @@
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy]
def index
@assistants = account_assistants.ordered
end
def show; end
def create
@assistant = account_assistants.create!(assistant_params)
end
def update
@assistant.update!(assistant_params)
end
def destroy
@assistant.destroy
head :no_content
end
private
def set_assistant
@assistant = account_assistants.find(params[:id])
end
def account_assistants
@account_assistants ||= Captain::Assistant.for_account(Current.account.id)
end
def assistant_params
params.require(:assistant).permit(:name, :description, config: [:product_name, :feature_faq, :feature_memory])
end
end

View File

@@ -0,0 +1,58 @@
class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
before_action :set_documents, except: [:create]
before_action :set_document, only: [:show, :destroy]
before_action :set_assistant, only: [:create]
RESULTS_PER_PAGE = 25
def index
base_query = @documents
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
@documents_count = base_query.count
@documents = base_query.page(@current_page).per(RESULTS_PER_PAGE)
end
def show; end
def create
return render_could_not_create_error('Missing Assistant') if @assistant.nil?
@document = @assistant.documents.build(document_params)
@document.save!
end
def destroy
@document.destroy
head :no_content
end
private
def set_documents
@documents = Current.account.captain_documents.includes(:assistant).ordered
end
def set_document
@document = @documents.find(permitted_params[:id])
end
def set_assistant
@assistant = Current.account.captain_assistants.find_by(id: document_params[:assistant_id])
end
def set_current_page
@current_page = permitted_params[:page] || 1
end
def permitted_params
params.permit(:assistant_id, :page, :id, :account_id)
end
def document_params
params.require(:document).permit(:name, :external_link, :assistant_id)
end
end

View File

@@ -0,0 +1,39 @@
class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant
def index
@inboxes = @assistant.inboxes
end
def create
inbox = Current.account.inboxes.find(assistant_params[:inbox_id])
@captain_inbox = @assistant.captain_inboxes.build(inbox: inbox)
@captain_inbox.save!
end
def destroy
@captain_inbox = @assistant.captain_inboxes.find_by!(inbox_id: permitted_params[:inbox_id])
@captain_inbox.destroy!
head :no_content
end
private
def set_assistant
@assistant = account_assistants.find(permitted_params[:assistant_id])
end
def account_assistants
@account_assistants ||= Current.account.captain_assistants
end
def permitted_params
params.permit(:assistant_id, :id, :account_id, :inbox_id)
end
def assistant_params
params.require(:inbox).permit(:inbox_id)
end
end

View File

@@ -1,34 +0,0 @@
class Api::V1::Accounts::ResponseSourcesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :check_authorization
before_action :find_response_source, only: [:add_document, :remove_document]
def parse
links = PageCrawlerService.new(params[:link]).page_links
render json: { links: links }
end
def create
@response_source = Current.account.response_sources.new(response_source_params)
@response_source.save!
end
def add_document
@response_source.response_documents.create!(document_link: params[:document_link])
end
def remove_document
@response_source.response_documents.find(params[:document_id]).destroy!
end
private
def find_response_source
@response_source = Current.account.response_sources.find(params[:id])
end
def response_source_params
params.require(:response_source).permit(:name, :source_link,
response_documents_attributes: [:document_link])
end
end

View File

@@ -1,5 +1,29 @@
module Enterprise::Api::V1::Accounts::ConversationsController
extend ActiveSupport::Concern
included do
before_action :set_assistant, only: [:copilot]
end
def copilot
assistant = @conversation.inbox.captain_assistant
return render json: { message: I18n.t('captain.copilot_error') } unless assistant
response = Captain::Copilot::ChatService.new(
assistant,
messages: copilot_params[:previous_messages],
conversation_history: @conversation.to_llm_text
).execute(copilot_params[:message])
render json: { message: response }
end
def permitted_update_params
super.merge(params.permit(:sla_policy_id))
end
private
def copilot_params
params.permit(:previous_messages, :message, :assistant_id)
end
end

View File

@@ -1,8 +1,4 @@
module Enterprise::Api::V1::Accounts::InboxesController
def response_sources
@response_sources = @inbox.response_sources
end
def inbox_attributes
super + ee_inbox_attributes
end

View File

@@ -10,7 +10,7 @@ module Enterprise::SuperAdmin::AppConfigsController
when 'internal'
@allowed_configs = internal_config_options
when 'captain'
@allowed_configs = %w[CAPTAIN_API_URL CAPTAIN_APP_URL]
@allowed_configs = %w[CAPTAIN_OPEN_AI_API_KEY]
else
super
end

View File

@@ -0,0 +1,30 @@
class Enterprise::Webhooks::FirecrawlController < ActionController::API
def process_payload
if crawl_page_event?
Captain::Tools::FirecrawlParserJob.perform_later(
assistant_id: permitted_params[:assistant_id],
payload: permitted_params[:data]
)
end
head :ok
end
private
def crawl_page_event?
permitted_params[:type] == 'crawl.page'
end
def permitted_params
params.permit(
:type,
:assistant_id,
:success,
:id,
:metadata,
:format,
:firecrawl,
{ data: {} }
)
end
end

View File

@@ -1,76 +0,0 @@
class SuperAdmin::ResponseSourcesController < SuperAdmin::EnterpriseBaseController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`
# actions.
#
# def find_resource(param)
# Foo.find_by!(slug: param)
# end
# The result of this lookup will be available as `requested_resource`
# Override this if you have certain roles that require a subset
# this will be used to set the records shown on the `index` action.
#
# def scoped_resource
# if current_user.super_admin?
# resource_class
# else
# resource_class.with_less_stuff
# end
# end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
# def resource_params
# params.require(resource_class.model_name.param_key).
# permit(dashboard.permitted_attributes(action_name)).
# transform_values { |value| value == "" ? nil : value }
# end
# See https://administrate-demo.herokuapp.com/customizing_controller_actions
# for more information
before_action :set_response_source, only: %i[chat process_chat]
def chat; end
def process_chat
previous_messages = []
get_previous_messages(previous_messages)
robin_response = ChatGpt.new(
Enterprise::MessageTemplates::ResponseBotService.response_sections(params[:message], @response_source)
).generate_response(
params[:message], previous_messages
)
message_content = robin_response['response']
if robin_response['context_ids'].present?
message_content += Enterprise::MessageTemplates::ResponseBotService.generate_sources_section(robin_response['context_ids'])
end
render json: { message: message_content }
end
private
def get_previous_messages(previous_messages)
params[:previous_messages].each do |message|
role = message['type'] == 'user' ? 'user' : 'system'
previous_messages << { content: message['message'], role: role }
end
end
def set_response_source
@response_source = requested_resource
end
end

View File

@@ -0,0 +1,7 @@
module Enterprise::AsyncDispatcher
def listeners
super + [
CaptainListener.instance
]
end
end

View File

@@ -1,5 +1,12 @@
# TODO: Move this values to features.yml itself
# No need to replicate the same values in two places
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-captain'
config_key: 'captain'
enterprise: true
custom_branding:
name: 'Custom Branding'
description: 'Apply your own branding to this installation.'

View File

@@ -0,0 +1,90 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
MAX_MESSAGE_LENGTH = 10_000
def perform(conversation, assistant)
@conversation = conversation
@assistant = assistant
ActiveRecord::Base.transaction do
generate_and_process_response
end
rescue StandardError => e
handle_error(e)
end
private
delegate :account, :inbox, to: :@conversation
def generate_and_process_response
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
@conversation.messages.incoming.last.content,
collect_previous_messages
)
return process_action('handoff') if handoff_requested?
create_messages
end
def collect_previous_messages
@conversation
.messages
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.map do |message|
{
content: message.content,
role: determine_role(message)
}
end
end
def determine_role(message)
message.message_type == 'incoming' ? 'user' : 'system'
end
def handoff_requested?
@response['response'] == 'conversation_handoff'
end
def process_action(action)
case action
when 'handoff'
create_handoff_message
@conversation.bot_handoff!
end
end
def create_handoff_message
create_outgoing_message('Transferring to another agent for further assistance.')
end
def create_messages
validate_message_content!(@response['response'])
create_outgoing_message(@response['response'])
end
def validate_message_content!(content)
raise ArgumentError, 'Message content cannot be blank' if content.blank?
end
def create_outgoing_message(message_content)
@conversation.messages.create!(
message_type: :outgoing,
account_id: account.id,
inbox_id: inbox.id,
content: message_content
)
end
def handle_error(error)
log_error(error)
process_action('handoff')
true
end
def log_error(error)
ChatwootExceptionTracker.new(error, account: account).capture_exception
end
end

View File

@@ -0,0 +1,40 @@
class Captain::Documents::CrawlJob < ApplicationJob
queue_as :low
def perform(document)
if InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').present?
perform_firecrawl_crawl(document)
else
perform_simple_crawl(document)
end
end
private
def perform_simple_crawl(document)
page_links = Captain::Tools::SimplePageCrawlService.new(document.external_link).page_links
page_links.each do |page_link|
Captain::Tools::SimplePageCrawlParserJob.perform_later(
assistant_id: document.assistant_id,
page_link: page_link
)
end
Captain::Tools::SimplePageCrawlParserJob.perform_later(
assistant_id: document.assistant_id,
page_link: document.external_link
)
end
def perform_firecrawl_crawl(document)
webhook_url = Rails.application.routes.url_helpers.enterprise_webhooks_firecrawl_url
Captain::Tools::FirecrawlService
.new
.perform(
document.external_link,
"#{webhook_url}?assistant_id=#{document.assistant_id}"
)
end
end

View File

@@ -0,0 +1,29 @@
class Captain::Documents::ResponseBuilderJob < ApplicationJob
queue_as :low
def perform(document)
reset_previous_responses(document)
faqs = Captain::Llm::FaqGeneratorService.new(document.content).generate
faqs.each do |faq|
create_response(faq, document)
end
end
private
def reset_previous_responses(response_document)
response_document.responses.destroy_all
end
def create_response(faq, document)
document.responses.create!(
question: faq['question'],
answer: faq['answer'],
assistant: document.assistant,
document: document
)
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "Error in creating response document: #{e.message}"
end
end

View File

@@ -0,0 +1,8 @@
class Captain::Llm::UpdateEmbeddingJob < ApplicationJob
queue_as :low
def perform(record, content)
embedding = Captain::Llm::EmbeddingService.new.get_embedding(content)
record.update!(embedding: embedding)
end
end

View File

@@ -0,0 +1,20 @@
class Captain::Tools::FirecrawlParserJob < ApplicationJob
queue_as :low
def perform(assistant_id:, payload:)
assistant = Captain::Assistant.find(assistant_id)
metadata = payload[:metadata]
document = assistant.documents.find_or_initialize_by(
external_link: metadata[:ogUrl]
)
document.update!(
content: payload[:markdown],
name: metadata[:ogTitle],
status: :available
)
rescue StandardError => e
raise "Failed to parse FireCrawl data: #{e.message}"
end
end

View File

@@ -0,0 +1,21 @@
class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
queue_as :low
def perform(assistant_id:, page_link:)
assistant = Captain::Assistant.find(assistant_id)
crawler = Captain::Tools::SimplePageCrawlService.new(page_link)
page_title = crawler.page_title || ''
content = crawler.body_text_content || ''
document = assistant.documents.find_or_initialize_by(
external_link: page_link
)
document.update!(
name: page_title[0..254], content: content[0..14_999], status: :available
)
rescue StandardError => e
raise "Failed to parse data: #{page_link} #{e.message}"
end
end

View File

@@ -2,32 +2,14 @@ module Enterprise::Account::ConversationsResolutionSchedulerJob
def perform
super
# TODO: remove this when response bot is remove in favor of captain
resolve_response_bot_conversations
# This is responsible for resolving captain conversations
resolve_captain_conversations
end
private
def resolve_response_bot_conversations
# This is responsible for resolving response bot conversations
Account.feature_response_bot.all.find_each(batch_size: 100) do |account|
account.inboxes.each do |inbox|
Captain::InboxPendingConversationsResolutionJob.perform_later(inbox) if inbox.response_bot_enabled?
end
end
end
def resolve_captain_conversations
Integrations::Hook.where(app_id: 'captain').all.find_each(batch_size: 100) do |hook|
next unless hook.enabled?
inboxes = Inbox.where(id: hook.settings['inbox_ids'].split(','))
inboxes.each do |inbox|
Captain::InboxPendingConversationsResolutionJob.perform_later(inbox)
end
CaptainInbox.all.find_each(batch_size: 100) do |captain_inbox|
Captain::InboxPendingConversationsResolutionJob.perform_later(captain_inbox.inbox)
end
end
end

View File

@@ -1,7 +0,0 @@
class ResponseBot::ResponseBotJob < ApplicationJob
queue_as :medium
def perform(conversation)
::Enterprise::MessageTemplates::ResponseBotService.new(conversation: conversation).perform
end
end

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