mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 12:08:01 +00:00
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:
@@ -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 \
|
||||
|
||||
@@ -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'
|
||||
|
||||
8
.github/workflows/run_foss_spec.yml
vendored
8
.github/workflows/run_foss_spec.yml
vendored
@@ -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: ''
|
||||
|
||||
89
.github/workflows/run_response_bot_spec.yml
vendored
89
.github/workflows/run_response_bot_spec.yml
vendored
@@ -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
|
||||
2
Gemfile
2
Gemfile
@@ -175,6 +175,8 @@ gem 'pgvector'
|
||||
# Convert Website HTML to Markdown
|
||||
gem 'reverse_markdown'
|
||||
|
||||
gem 'ruby-openai'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -22,3 +22,5 @@ class AsyncDispatcher < BaseDispatcher
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
AsyncDispatcher.prepend_mod_with('AsyncDispatcher')
|
||||
|
||||
19
app/javascript/dashboard/api/captain/assistant.js
Normal file
19
app/javascript/dashboard/api/captain/assistant.js
Normal 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();
|
||||
19
app/javascript/dashboard/api/captain/document.js
Normal file
19
app/javascript/dashboard/api/captain/document.js
Normal 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();
|
||||
26
app/javascript/dashboard/api/captain/inboxes.js
Normal file
26
app/javascript/dashboard/api/captain/inboxes.js
Normal 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();
|
||||
21
app/javascript/dashboard/api/captain/response.js
Normal file
21
app/javascript/dashboard/api/captain/response.js
Normal 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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
7
app/javascript/dashboard/store/captain/assistant.js
Normal file
7
app/javascript/dashboard/store/captain/assistant.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import CaptainAssistantAPI from 'dashboard/api/captain/assistant';
|
||||
import { createStore } from './storeFactory';
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainAssistant',
|
||||
API: CaptainAssistantAPI,
|
||||
});
|
||||
7
app/javascript/dashboard/store/captain/document.js
Normal file
7
app/javascript/dashboard/store/captain/document.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import CaptainDocumentAPI from 'dashboard/api/captain/document';
|
||||
import { createStore } from './storeFactory';
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainDocument',
|
||||
API: CaptainDocumentAPI,
|
||||
});
|
||||
22
app/javascript/dashboard/store/captain/inboxes.js
Normal file
22
app/javascript/dashboard/store/captain/inboxes.js
Normal 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);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
7
app/javascript/dashboard/store/captain/response.js
Normal file
7
app/javascript/dashboard/store/captain/response.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import CaptainResponseAPI from 'dashboard/api/captain/response';
|
||||
import { createStore } from './storeFactory';
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainResponse',
|
||||
API: CaptainResponseAPI,
|
||||
});
|
||||
140
app/javascript/dashboard/store/captain/storeFactory.js
Normal file
140
app/javascript/dashboard/store/captain/storeFactory.js
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -38,10 +38,6 @@ class InboxPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def response_sources?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def create?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
35
app/services/llm_formatter/contact_llm_formatter.rb
Normal file
35
app/services/llm_formatter/contact_llm_formatter.rb
Normal 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
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -7,11 +7,14 @@ 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
|
||||
|
||||
@@ -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 ----- ##
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
90
db/migrate/20250104200055_create_captain_tables.rb
Normal file
90
db/migrate/20250104200055_create_captain_tables.rb
Normal 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
|
||||
10
db/migrate/20250104210328_remove_robin_tables.rb
Normal file
10
db/migrate/20250104210328_remove_robin_tables.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
class RemoveNotNullFromCaptainDocuments < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
change_column_null :captain_documents, :name, true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddConfigToCaptainAssistant < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :captain_assistants, :config, :jsonb, default: {}, null: false
|
||||
end
|
||||
end
|
||||
11
db/migrate/20250108031358_create_captain_inbox.rb
Normal file
11
db/migrate/20250108031358_create_captain_inbox.rb
Normal 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
|
||||
@@ -0,0 +1,5 @@
|
||||
class RemoveIndexFromCaptainAssistants < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
remove_index :captain_assistants, [:account_id, :name], if_exists: true
|
||||
end
|
||||
end
|
||||
64
db/schema.rb
64
db/schema.rb
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
module Enterprise::AsyncDispatcher
|
||||
def listeners
|
||||
super + [
|
||||
CaptainListener.instance
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -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.'
|
||||
|
||||
@@ -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
|
||||
40
enterprise/app/jobs/captain/documents/crawl_job.rb
Normal file
40
enterprise/app/jobs/captain/documents/crawl_job.rb
Normal 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
|
||||
@@ -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
|
||||
8
enterprise/app/jobs/captain/llm/update_embedding_job.rb
Normal file
8
enterprise/app/jobs/captain/llm/update_embedding_job.rb
Normal 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
|
||||
20
enterprise/app/jobs/captain/tools/firecrawl_parser_job.rb
Normal file
20
enterprise/app/jobs/captain/tools/firecrawl_parser_job.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user