Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e55d089f01 | ||
|
|
d0e84373ee | ||
|
|
62152321bc | ||
|
|
c6714b20c2 | ||
|
|
0f2263ec94 | ||
|
|
784e0468c3 | ||
|
|
75c9b38099 | ||
|
|
f8c155dd9e | ||
|
|
b9cf410b1f | ||
|
|
e6d96b14ac | ||
|
|
f0b3f025ff | ||
|
|
5b32761d72 | ||
|
|
7146471270 | ||
|
|
8d6ea64848 | ||
|
|
27b0a22019 | ||
|
|
611635d3d7 | ||
|
|
02178eb930 | ||
|
|
a1413d1656 | ||
|
|
a1747eeb62 | ||
|
|
f90a324ddf | ||
|
|
3f5d4f46f8 | ||
|
|
7ae916e2fc | ||
|
|
c623d6079b | ||
|
|
f0876e9877 | ||
|
|
92b0c2d4f5 | ||
|
|
0a80076387 | ||
|
|
a59f53d00d | ||
|
|
143f394a18 | ||
|
|
8e98fd26b5 | ||
|
|
4c85a1c6c0 | ||
|
|
6e28c4fcf8 | ||
|
|
7f9d711845 | ||
|
|
31436412fb | ||
|
|
84d2135a04 | ||
|
|
5bd739739e | ||
|
|
3b42b6c3bb | ||
|
|
d3b8edd522 | ||
|
|
ac7cac33a8 | ||
|
|
4d98b1871a | ||
|
|
4f214dd77a | ||
|
|
94640628eb | ||
|
|
c9c9a011fe | ||
|
|
747a856181 | ||
|
|
be5248ad68 | ||
|
|
a9ead61a89 | ||
|
|
c5b72239c6 | ||
|
|
824b5a8113 | ||
|
|
913ff2821a | ||
|
|
05c004a3cb | ||
|
|
bd4c572aea | ||
|
|
7e672e5c71 | ||
|
|
1622ba1b7f | ||
|
|
d59f2d7bd1 | ||
|
|
959f7447c3 | ||
|
|
39a6485c2a | ||
|
|
bcaca9d3d3 | ||
|
|
4de56468b7 | ||
|
|
6f84899beb | ||
|
|
bf78dde488 | ||
|
|
1df0159f65 | ||
|
|
b2a1e3282c |
@@ -21,6 +21,7 @@ gem 'telephone_number'
|
||||
gem 'time_diff'
|
||||
gem 'tzinfo-data'
|
||||
gem 'valid_email2'
|
||||
gem 'octokit'
|
||||
# compress javascript config.assets.js_compressor
|
||||
gem 'uglifier'
|
||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
||||
|
||||
@@ -591,6 +591,9 @@ GEM
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
octokit (10.0.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
@@ -817,6 +820,9 @@ GEM
|
||||
sprockets (> 3.0)
|
||||
sprockets-rails
|
||||
tilt
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
scout_apm (5.3.3)
|
||||
parser
|
||||
scss_lint (0.60.0)
|
||||
@@ -1057,6 +1063,7 @@ DEPENDENCIES
|
||||
net-smtp (~> 0.3.4)
|
||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||
newrelic_rpm
|
||||
octokit
|
||||
omniauth (>= 2.1.2)
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook
|
||||
before_action :ensure_hook_exists, except: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
def repositories
|
||||
repositories = github_service.repositories
|
||||
filtered_repos = repositories.map do |repo|
|
||||
{
|
||||
full_name: repo[:full_name] || repo.full_name,
|
||||
private: repo[:private] || repo.private
|
||||
}
|
||||
end
|
||||
render json: filtered_repos
|
||||
end
|
||||
|
||||
def search_repositories
|
||||
repositories = github_service.search_repositories(params[:q])
|
||||
filtered_repos = repositories.map do |repo|
|
||||
{
|
||||
full_name: repo[:full_name] || repo.full_name,
|
||||
private: repo[:private] || repo.private
|
||||
}
|
||||
end
|
||||
render json: filtered_repos
|
||||
end
|
||||
|
||||
def assignees
|
||||
assignees = github_service.assignees(repo_full_name)
|
||||
filtered_assignees = assignees.map do |assignee|
|
||||
{
|
||||
login: assignee.login,
|
||||
avatar_url: assignee.avatar_url
|
||||
}
|
||||
end
|
||||
render json: filtered_assignees
|
||||
end
|
||||
|
||||
def labels
|
||||
labels = github_service.labels(repo_full_name)
|
||||
filtered_labels = labels.map do |label|
|
||||
{
|
||||
name: label.name,
|
||||
color: label.color
|
||||
}
|
||||
end
|
||||
render json: filtered_labels
|
||||
end
|
||||
|
||||
def search_issues
|
||||
issues = github_service.search_issues(repo_full_name, params[:q])
|
||||
filtered_issues = issues.map do |issue|
|
||||
{
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
html_url: issue.html_url
|
||||
}
|
||||
end
|
||||
render json: filtered_issues
|
||||
end
|
||||
|
||||
def create_issue
|
||||
issue_data = github_service.create_issue(
|
||||
repo_full_name,
|
||||
params[:title],
|
||||
params[:body],
|
||||
issue_options
|
||||
)
|
||||
|
||||
github_issue = create_linked_issue(issue_data)
|
||||
render json: issue_response(github_issue), status: :created
|
||||
end
|
||||
|
||||
def link_issue
|
||||
issue_data = github_service.issue(repo_full_name, params[:issue_number])
|
||||
github_issue = create_linked_issue(issue_data)
|
||||
render json: issue_response(github_issue), status: :created
|
||||
end
|
||||
|
||||
def linked_issues
|
||||
issues = GithubIssue.where(conversation_id: params[:conversation_id])
|
||||
|
||||
render json: issues.map do |issue|
|
||||
{
|
||||
id: issue.id,
|
||||
issue_number: issue.issue_number,
|
||||
title: issue.issue_title,
|
||||
html_url: issue.html_url,
|
||||
repo_full_name: issue.repo_full_name,
|
||||
linked_by: {
|
||||
id: issue.linked_by.id,
|
||||
name: issue.linked_by.name
|
||||
},
|
||||
created_at: issue.created_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def unlink_issue
|
||||
github_issue = GithubIssue.find(params[:id])
|
||||
github_issue.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_hook
|
||||
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'github')
|
||||
end
|
||||
|
||||
def ensure_hook_exists
|
||||
render json: { error: 'GitHub integration not configured' }, status: :unauthorized unless @hook
|
||||
end
|
||||
|
||||
def github_service
|
||||
@github_service ||= Github::GithubService.new(hook: @hook)
|
||||
end
|
||||
|
||||
def repo_full_name
|
||||
params[:repo_full_name] || "#{params[:owner]}/#{params[:repo]}"
|
||||
end
|
||||
|
||||
def issue_options
|
||||
options = {}
|
||||
options[:assignees] = params[:assignees] if params[:assignees].present?
|
||||
options[:labels] = params[:labels] if params[:labels].present?
|
||||
options
|
||||
end
|
||||
|
||||
def create_linked_issue(issue_data)
|
||||
GithubIssue.create!(
|
||||
conversation_id: params[:conversation_id],
|
||||
account: Current.account,
|
||||
repo_full_name: repo_full_name,
|
||||
issue_number: issue_data.number,
|
||||
issue_title: issue_data.title,
|
||||
html_url: issue_data.html_url,
|
||||
linked_by: Current.user
|
||||
)
|
||||
end
|
||||
|
||||
def issue_response(github_issue)
|
||||
{
|
||||
id: github_issue.id,
|
||||
issue_number: github_issue.issue_number,
|
||||
title: github_issue.issue_title,
|
||||
html_url: github_issue.html_url,
|
||||
repo_full_name: github_issue.repo_full_name
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,160 @@
|
||||
class Github::CallbacksController < ApplicationController
|
||||
include Github::IntegrationHelper
|
||||
|
||||
def show
|
||||
# Validate account context early for all flows that require it
|
||||
account if params[:code].present?
|
||||
|
||||
if params[:installation_id].present? && params[:code].present?
|
||||
# Both installation and OAuth code present - handle both
|
||||
handle_installation_with_oauth
|
||||
elsif params[:installation_id].present?
|
||||
# Only installation_id present - redirect to OAuth
|
||||
handle_installation
|
||||
else
|
||||
# Only OAuth code present - handle authorization
|
||||
handle_authorization
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Github callback error: #{e.message}")
|
||||
redirect_to fallback_redirect_uri
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_installation_with_oauth
|
||||
# Handle both installation and OAuth in one go
|
||||
installation_id = params[:installation_id]
|
||||
|
||||
@response = oauth_client.auth_code.get_token(
|
||||
params[:code],
|
||||
redirect_uri: "#{base_url}/github/callback"
|
||||
)
|
||||
|
||||
handle_response(installation_id)
|
||||
end
|
||||
|
||||
def handle_installation
|
||||
if params[:setup_action] == 'install'
|
||||
installation_id = params[:installation_id]
|
||||
|
||||
redirect_to build_oauth_url(installation_id)
|
||||
else
|
||||
Rails.logger.error("Unknown setup_action: #{params[:setup_action]}")
|
||||
redirect_to github_integration_settings_url
|
||||
end
|
||||
end
|
||||
|
||||
def handle_authorization
|
||||
@response = oauth_client.auth_code.get_token(
|
||||
params[:code],
|
||||
redirect_uri: "#{base_url}/github/callback"
|
||||
)
|
||||
|
||||
handle_response
|
||||
end
|
||||
|
||||
def build_oauth_url(installation_id)
|
||||
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
||||
|
||||
# Store installation_id in session for later use
|
||||
session[:github_installation_id] = installation_id
|
||||
|
||||
# For now, redirect to a page that will initiate OAuth with proper account context
|
||||
# This is a temporary solution until we have a proper account-agnostic setup
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github?setup_action=install&installation_id=#{installation_id}"
|
||||
end
|
||||
|
||||
def oauth_client
|
||||
app_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
||||
app_secret = GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
|
||||
|
||||
OAuth2::Client.new(
|
||||
app_id,
|
||||
app_secret,
|
||||
{
|
||||
site: 'https://github.com',
|
||||
token_url: '/login/oauth/access_token',
|
||||
authorize_url: '/login/oauth/authorize'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def handle_response(installation_id = nil)
|
||||
settings = build_hook_settings(installation_id)
|
||||
hook = create_integration_hook(settings)
|
||||
hook.save!
|
||||
|
||||
cleanup_session_data
|
||||
redirect_to github_redirect_uri
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Github callback error: #{e.message}")
|
||||
redirect_to fallback_redirect_uri
|
||||
end
|
||||
|
||||
def build_hook_settings(installation_id)
|
||||
settings = {
|
||||
token_type: parsed_body['token_type'],
|
||||
scope: parsed_body['scope']
|
||||
}
|
||||
|
||||
settings[:installation_id] = installation_id || session[:github_installation_id]
|
||||
settings.compact
|
||||
end
|
||||
|
||||
def create_integration_hook(settings)
|
||||
account.hooks.new(
|
||||
access_token: parsed_body['access_token'],
|
||||
status: 'enabled',
|
||||
app_id: 'github',
|
||||
settings: settings
|
||||
)
|
||||
end
|
||||
|
||||
def cleanup_session_data
|
||||
session.delete(:github_installation_id)
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= account_from_state
|
||||
end
|
||||
|
||||
def account_from_state
|
||||
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
|
||||
|
||||
# Try signed GlobalID first (installation flow)
|
||||
account = GlobalID::Locator.locate_signed(params[:state])
|
||||
return account if account
|
||||
|
||||
# Fallback to JWT token (direct OAuth flow)
|
||||
account_id = verify_github_token(params[:state])
|
||||
return Account.find(account_id) if account_id
|
||||
|
||||
raise 'Invalid or expired state'
|
||||
rescue StandardError
|
||||
raise ActionController::BadRequest, 'Invalid account context'
|
||||
end
|
||||
|
||||
def github_redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/github"
|
||||
end
|
||||
|
||||
def github_integration_settings_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github"
|
||||
end
|
||||
|
||||
def fallback_redirect_uri
|
||||
github_redirect_uri
|
||||
rescue StandardError
|
||||
# Fallback if no account context available
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/settings/integrations"
|
||||
end
|
||||
|
||||
def parsed_body
|
||||
@parsed_body ||= @response.response.parsed
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
end
|
||||
@@ -42,7 +42,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
|
||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI],
|
||||
'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
|
||||
}
|
||||
|
||||
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
module Github::IntegrationHelper
|
||||
# Generates a signed JWT token for Github integration
|
||||
#
|
||||
# @param account_id [Integer] The account ID to encode in the token
|
||||
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||
def generate_github_token(account_id)
|
||||
return if github_client_secret.blank?
|
||||
|
||||
JWT.encode(github_token_payload(account_id), github_client_secret, 'HS256')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to generate Github token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def github_token_payload(account_id)
|
||||
{
|
||||
sub: account_id,
|
||||
iat: Time.current.to_i
|
||||
}
|
||||
end
|
||||
|
||||
# Verifies and decodes a Github JWT token
|
||||
#
|
||||
# @param token [String] The JWT token to verify
|
||||
# @return [Integer, nil] The account ID from the token or nil if invalid
|
||||
def verify_github_token(token)
|
||||
return if token.blank? || github_client_secret.blank?
|
||||
|
||||
github_decode_token(token, github_client_secret)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def github_client_secret
|
||||
@github_client_secret ||= GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
|
||||
end
|
||||
|
||||
def github_decode_token(token, secret)
|
||||
JWT.decode(token, secret, true, {
|
||||
algorithm: 'HS256',
|
||||
verify_expiration: true
|
||||
}).first['sub']
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error verifying Github token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,6 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -60,10 +59,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
||||
@@ -164,116 +159,73 @@ const handleDocumentableClick = () => {
|
||||
<span class="text-n-slate-11 text-sm line-clamp-5">
|
||||
{{ answer }}
|
||||
</span>
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
|
||||
>
|
||||
<Policy v-if="showActions" :permissions="['administrator']">
|
||||
<div class="flex items-center gap-2 sm:gap-5 w-full">
|
||||
<Button
|
||||
v-if="status === 'pending'"
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')"
|
||||
icon="i-lucide-circle-check-big"
|
||||
sm
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({ action: 'approve', value: 'approve' })
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE')"
|
||||
icon="i-lucide-pencil-line"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({
|
||||
action: 'edit',
|
||||
value: 'edit',
|
||||
})
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE')"
|
||||
icon="i-lucide-trash"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({ action: 'delete', value: 'delete' })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</Policy>
|
||||
<div
|
||||
class="flex items-center gap-3"
|
||||
:class="{ 'justify-between w-full': !showActions }"
|
||||
>
|
||||
<div class="inline-flex items-center gap-3 min-w-0">
|
||||
<div v-if="!compact" class="items-center justify-between hidden lg:flex">
|
||||
<div class="inline-flex items-center">
|
||||
<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
|
||||
v-if="documentable"
|
||||
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
|
||||
>
|
||||
<span
|
||||
v-if="status === 'approved'"
|
||||
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
class="inline-flex items-center gap-1 truncate over"
|
||||
>
|
||||
<Icon icon="i-woot-captain" class="size-3.5" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<div
|
||||
v-if="documentable"
|
||||
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1 min-w-0"
|
||||
>
|
||||
<Icon
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
icon="i-ph-files-light"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="documentable.type === 'User'"
|
||||
icon="i-ph-user-circle-plus"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
icon="i-ph-chat-circle-dots"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<span
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
class="truncate"
|
||||
:title="documentable.name"
|
||||
>
|
||||
<i class="i-ph-files-light text-base" />
|
||||
<span class="max-w-96 truncate" :title="documentable.name">
|
||||
{{ documentable.name }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="documentable.type === 'User'"
|
||||
class="inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-ph-user-circle-plus text-base" />
|
||||
<span
|
||||
v-else-if="documentable.type === 'User'"
|
||||
class="truncate"
|
||||
class="max-w-96 truncate"
|
||||
:title="documentable.available_name"
|
||||
>
|
||||
{{ documentable.available_name }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
class="hover:underline truncate cursor-pointer"
|
||||
role="button"
|
||||
@click="handleDocumentableClick"
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
class="inline-flex items-center gap-1 group cursor-pointer"
|
||||
role="button"
|
||||
@click="handleDocumentableClick"
|
||||
>
|
||||
<i class="i-ph-chat-circle-dots text-base" />
|
||||
<span class="group-hover:underline">
|
||||
{{
|
||||
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
|
||||
id: documentable.display_id,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<span v-else />
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
|
||||
v-if="status !== 'approved'"
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
||||
>
|
||||
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
|
||||
{{ timestamp }}
|
||||
<i
|
||||
class="i-ph-stack text-base"
|
||||
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
|
||||
/>
|
||||
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
|
||||
<div class="p-3 bg-n-alpha-2 rounded-xl">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Call us" slate class="!text-n-blue-text w-full" />
|
||||
<Button
|
||||
label="Visit our website"
|
||||
slate
|
||||
class="!text-n-blue-text w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,32 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="px-3 py-2.5">
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<div class="pt-2.5 flex flex-col gap-2">
|
||||
<h6 class="font-semibold">{{ message.title }}</h6>
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="Call us to order" link class="hover:!no-underline" />
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="Visit our store" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="See options" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
|
||||
>
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="No, that will be all" link class="hover:!no-underline">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button
|
||||
label="I want to talk to an agents"
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
|
||||
<span v-dompurify-html="message.content" class="prose prose-bubble" />
|
||||
</div>
|
||||
</template>
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
<script setup>
|
||||
import CallToAction from '../../bubbles/Template/CallToAction.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/CallToAction"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Call To Action">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<CallToAction :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup>
|
||||
import Card from '../../bubbles/Template/Card.vue';
|
||||
|
||||
const message = {
|
||||
title: 'Two in one cake (1 pound)',
|
||||
content: 'Customize your order for special occasions',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Card"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Card">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Card :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
<script setup>
|
||||
import ListPicker from '../../bubbles/Template/ListPicker.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you? `,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/ListPicker"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="ListPicker">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<ListPicker :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup>
|
||||
import Media from '../../bubbles/Template/Media.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Media"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Image Media">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Media :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
<script setup>
|
||||
import QuickReply from '../../bubbles/Template/QuickReply.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you?`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/QuickReply"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Quick Replies">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<QuickReply :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup>
|
||||
import Text from '../../bubbles/Template/Text.vue';
|
||||
|
||||
const message = {
|
||||
content: 'Hello John, how may we assist you?',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Text"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Default Text">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Text :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -15,6 +15,20 @@
|
||||
},
|
||||
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
|
||||
},
|
||||
"GITHUB": {
|
||||
"TITLE": "Connect Github",
|
||||
"LABEL": "Github Client ID",
|
||||
"PLACEHOLDER": "Enter your Github Client ID",
|
||||
"HELP": "Enter your Github Client ID",
|
||||
"CANCEL": "Cancel",
|
||||
"SUBMIT": "Connect",
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the integration?",
|
||||
"MESSAGE": "Are you sure you want to delete the integration?",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"CANCEL": "Cancel"
|
||||
}
|
||||
},
|
||||
"HEADER": "Integrations",
|
||||
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
|
||||
"LEARN_MORE": "Learn more about integrations",
|
||||
@@ -338,6 +352,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"CAPTAIN": {
|
||||
"NAME": "Captain",
|
||||
"HEADER_KNOW_MORE": "Know more",
|
||||
@@ -861,7 +876,6 @@
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"PENDING_FAQS": "Pending FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
@@ -901,10 +915,6 @@
|
||||
"APPROVED": "Approved",
|
||||
"ALL": "All"
|
||||
},
|
||||
"PENDING_BANNER": {
|
||||
"TITLE": "Captain has found some FAQs your customers were looking for.",
|
||||
"ACTION": "Click here to review"
|
||||
},
|
||||
"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",
|
||||
@@ -936,9 +946,9 @@
|
||||
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
|
||||
},
|
||||
"OPTIONS": {
|
||||
"APPROVE": "Approve",
|
||||
"EDIT_RESPONSE": "Edit",
|
||||
"DELETE_RESPONSE": "Delete"
|
||||
"APPROVE": "Mark as approved",
|
||||
"EDIT_RESPONSE": "Edit FAQ",
|
||||
"DELETE_RESPONSE": "Delete FAQ"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No FAQs Found",
|
||||
|
||||
@@ -10,7 +10,6 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
|
||||
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
|
||||
import DocumentsIndex from './documents/Index.vue';
|
||||
import ResponsesIndex from './responses/Index.vue';
|
||||
import ResponsesPendingIndex from './responses/Pending.vue';
|
||||
import CustomToolsIndex from './tools/Index.vue';
|
||||
|
||||
export const routes = [
|
||||
@@ -126,19 +125,6 @@ export const routes = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/responses/pending'),
|
||||
component: ResponsesPendingIndex,
|
||||
name: 'captain_responses_pending',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/tools'),
|
||||
component: CustomToolsIndex,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
@@ -35,6 +37,7 @@ const selectedResponse = ref(null);
|
||||
const deleteDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const selectedStatus = ref('all');
|
||||
const selectedAssistant = ref('all');
|
||||
const dialogType = ref('');
|
||||
const searchQuery = ref('');
|
||||
@@ -42,17 +45,54 @@ const { t } = useI18n();
|
||||
|
||||
const createDialog = ref(null);
|
||||
|
||||
const isStatusFilterOpen = ref(false);
|
||||
const shouldShowDropdown = computed(() => {
|
||||
if (assistants.value.length === 0) return false;
|
||||
|
||||
return !isFetching.value;
|
||||
});
|
||||
|
||||
const pendingCount = useMapGetter('captainResponses/getPendingCount');
|
||||
const statusOptions = computed(() =>
|
||||
['all', 'pending', 'approved'].map(key => ({
|
||||
label: t(`CAPTAIN.RESPONSES.STATUS.${key.toUpperCase()}`),
|
||||
value: key,
|
||||
action: 'filter',
|
||||
}))
|
||||
);
|
||||
|
||||
const filteredResponses = computed(() => {
|
||||
return selectedStatus.value === 'pending'
|
||||
? responses.value.filter(r => r.status === 'pending')
|
||||
: responses.value;
|
||||
});
|
||||
|
||||
const selectedStatusLabel = computed(() => {
|
||||
const status = statusOptions.value.find(
|
||||
option => option.value === selectedStatus.value
|
||||
);
|
||||
return t('CAPTAIN.RESPONSES.FILTER.STATUS', {
|
||||
selected: status ? status.label : '',
|
||||
});
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDialog.value.dialogRef.open();
|
||||
};
|
||||
const handleAccept = async () => {
|
||||
try {
|
||||
await store.dispatch('captainResponses/update', {
|
||||
id: selectedResponse.value.id,
|
||||
status: 'approved',
|
||||
});
|
||||
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
selectedResponse.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
dialogType.value = 'create';
|
||||
@@ -65,7 +105,9 @@ const handleEdit = () => {
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
selectedResponse.value = responses.value.find(response => id === response.id);
|
||||
selectedResponse.value = filteredResponses.value.find(
|
||||
response => id === response.id
|
||||
);
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
@@ -73,6 +115,9 @@ const handleAction = ({ action, id }) => {
|
||||
if (action === 'edit') {
|
||||
handleEdit();
|
||||
}
|
||||
if (action === 'approve') {
|
||||
handleAccept();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -91,8 +136,10 @@ const handleCreateClose = () => {
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'approved' };
|
||||
|
||||
const filterParams = { page };
|
||||
if (selectedStatus.value !== 'all') {
|
||||
filterParams.status = selectedStatus.value;
|
||||
}
|
||||
if (selectedAssistant.value !== 'all') {
|
||||
filterParams.assistantId = selectedAssistant.value;
|
||||
}
|
||||
@@ -108,7 +155,7 @@ const hoveredCard = ref(null);
|
||||
|
||||
const bulkSelectionState = computed(() => {
|
||||
const selectedCount = bulkSelectedIds.value.size;
|
||||
const totalCount = responses.value?.length || 0;
|
||||
const totalCount = filteredResponses.value?.length || 0;
|
||||
|
||||
return {
|
||||
hasSelected: selectedCount > 0,
|
||||
@@ -121,13 +168,13 @@ const bulkCheckbox = computed({
|
||||
get: () => bulkSelectionState.value.allSelected,
|
||||
set: value => {
|
||||
bulkSelectedIds.value = value
|
||||
? new Set(responses.value.map(r => r.id))
|
||||
? new Set(filteredResponses.value.map(r => r.id))
|
||||
: new Set();
|
||||
},
|
||||
});
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = responses.value?.length || 0;
|
||||
const count = filteredResponses.value?.length || 0;
|
||||
return bulkSelectionState.value.allSelected
|
||||
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||
@@ -144,7 +191,7 @@ const handleCardSelect = id => {
|
||||
};
|
||||
|
||||
const fetchResponseAfterBulkAction = () => {
|
||||
const hasNoResponsesLeft = responses.value?.length === 0;
|
||||
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
|
||||
const currentPage = responseMeta.value?.page;
|
||||
|
||||
if (hasNoResponsesLeft) {
|
||||
@@ -161,6 +208,22 @@ const fetchResponseAfterBulkAction = () => {
|
||||
bulkSelectedIds.value = new Set();
|
||||
};
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
try {
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkApprove',
|
||||
Array.from(bulkSelectedIds.value)
|
||||
);
|
||||
|
||||
fetchResponseAfterBulkAction();
|
||||
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = page => {
|
||||
// Store current selection state before fetching new page
|
||||
const wasAllPageSelected = bulkSelectionState.value.allSelected;
|
||||
@@ -175,7 +238,7 @@ const onPageChange = page => {
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
if (responses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
onPageChange(responseMeta.value.page - 1);
|
||||
}
|
||||
};
|
||||
@@ -184,6 +247,12 @@ const onBulkDeleteSuccess = () => {
|
||||
fetchResponseAfterBulkAction();
|
||||
};
|
||||
|
||||
const handleStatusFilterChange = ({ value }) => {
|
||||
selectedStatus.value = value;
|
||||
isStatusFilterOpen.value = false;
|
||||
fetchResponses();
|
||||
};
|
||||
|
||||
const handleAssistantFilterChange = assistant => {
|
||||
selectedAssistant.value = assistant;
|
||||
fetchResponses();
|
||||
@@ -193,14 +262,9 @@ const debouncedSearch = debounce(async () => {
|
||||
fetchResponses();
|
||||
}, 500);
|
||||
|
||||
const navigateToPendingFAQs = () => {
|
||||
router.push({ name: 'captain_responses_pending' });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
fetchResponses();
|
||||
store.dispatch('captainResponses/fetchPendingCount');
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -212,8 +276,8 @@ onMounted(() => {
|
||||
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
|
||||
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!responses.length"
|
||||
:show-pagination-footer="!isFetching && !!responses.length"
|
||||
:is-empty="!filteredResponses.length"
|
||||
:show-pagination-footer="!isFetching && !!filteredResponses.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
@update:current-page="onPageChange"
|
||||
@click="handleCreate"
|
||||
@@ -251,7 +315,25 @@ onMounted(() => {
|
||||
v-if="!bulkSelectionState.hasSelected"
|
||||
class="flex gap-3 justify-between w-full items-center"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex gap-3">
|
||||
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
||||
<Button
|
||||
:label="selectedStatusLabel"
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="max-w-48"
|
||||
@click="isStatusFilterOpen = !isStatusFilterOpen"
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="isStatusFilterOpen"
|
||||
:menu-items="statusOptions"
|
||||
class="mt-2"
|
||||
@action="handleStatusFilterChange"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
@@ -262,7 +344,6 @@ onMounted(() => {
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
||||
class="w-64"
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
@@ -299,6 +380,15 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex gap-3 items-center">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
ghost
|
||||
icon="i-lucide-check"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkApprove"
|
||||
/>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||
sm
|
||||
@@ -316,19 +406,10 @@ onMounted(() => {
|
||||
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
<Banner
|
||||
v-if="pendingCount > 0"
|
||||
color="blue"
|
||||
class="mb-4"
|
||||
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
|
||||
@action="navigateToPendingFAQs"
|
||||
>
|
||||
{{ $t('CAPTAIN.RESPONSES.PENDING_BANNER.TITLE') }}
|
||||
</Banner>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ResponseCard
|
||||
v-for="response in responses"
|
||||
v-for="response in filteredResponses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
@@ -341,7 +422,6 @@ onMounted(() => {
|
||||
:is-selected="bulkSelectedIds.has(response.id)"
|
||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||
:show-menu="!bulkSelectedIds.has(response.id)"
|
||||
:show-actions="false"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
@select="handleCardSelect"
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
|
||||
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
|
||||
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
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 bulkDeleteDialog = ref(null);
|
||||
|
||||
const selectedAssistant = ref('all');
|
||||
const dialogType = ref('');
|
||||
const searchQuery = ref('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const createDialog = ref(null);
|
||||
|
||||
const shouldShowDropdown = computed(() => {
|
||||
if (assistants.value.length === 0) return false;
|
||||
return !isFetching.value;
|
||||
});
|
||||
|
||||
const backUrl = computed(() =>
|
||||
frontendURL(`accounts/${route.params.accountId}/captain/responses`)
|
||||
);
|
||||
|
||||
// Filter out approved responses in pending view
|
||||
const filteredResponses = computed(() =>
|
||||
responses.value.filter(response => response.status !== 'approved')
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDialog.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const handleAccept = async () => {
|
||||
try {
|
||||
await store.dispatch('captainResponses/update', {
|
||||
id: selectedResponse.value.id,
|
||||
status: 'approved',
|
||||
});
|
||||
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
selectedResponse.value = 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 = filteredResponses.value.find(
|
||||
response => id === response.id
|
||||
);
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
}
|
||||
if (action === 'edit') {
|
||||
handleEdit();
|
||||
}
|
||||
if (action === 'approve') {
|
||||
handleAccept();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleNavigationAction = ({ id, type }) => {
|
||||
if (type === 'Conversation') {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: id },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateClose = () => {
|
||||
dialogType.value = '';
|
||||
selectedResponse.value = null;
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'pending' };
|
||||
|
||||
if (selectedAssistant.value !== 'all') {
|
||||
filterParams.assistantId = selectedAssistant.value;
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
filterParams.search = searchQuery.value;
|
||||
}
|
||||
store.dispatch('captainResponses/get', filterParams);
|
||||
};
|
||||
|
||||
// Bulk action
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
const bulkSelectionState = computed(() => {
|
||||
const selectedCount = bulkSelectedIds.value.size;
|
||||
const totalCount = filteredResponses.value?.length || 0;
|
||||
|
||||
return {
|
||||
hasSelected: selectedCount > 0,
|
||||
isIndeterminate: selectedCount > 0 && selectedCount < totalCount,
|
||||
allSelected: totalCount > 0 && selectedCount === totalCount,
|
||||
};
|
||||
});
|
||||
|
||||
const bulkCheckbox = computed({
|
||||
get: () => bulkSelectionState.value.allSelected,
|
||||
set: value => {
|
||||
bulkSelectedIds.value = value
|
||||
? new Set(filteredResponses.value.map(r => r.id))
|
||||
: new Set();
|
||||
},
|
||||
});
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = filteredResponses.value?.length || 0;
|
||||
return bulkSelectionState.value.allSelected
|
||||
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const handleCardHover = (isHovered, id) => {
|
||||
hoveredCard.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const handleCardSelect = id => {
|
||||
const selected = new Set(bulkSelectedIds.value);
|
||||
selected[selected.has(id) ? 'delete' : 'add'](id);
|
||||
bulkSelectedIds.value = selected;
|
||||
};
|
||||
|
||||
const fetchResponseAfterBulkAction = () => {
|
||||
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
|
||||
const currentPage = responseMeta.value?.page;
|
||||
|
||||
if (hasNoResponsesLeft) {
|
||||
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
|
||||
fetchResponses(pageToFetch);
|
||||
} else {
|
||||
fetchResponses(currentPage);
|
||||
}
|
||||
|
||||
bulkSelectedIds.value = new Set();
|
||||
};
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
try {
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkApprove',
|
||||
Array.from(bulkSelectedIds.value)
|
||||
);
|
||||
|
||||
fetchResponseAfterBulkAction();
|
||||
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = page => {
|
||||
const wasAllPageSelected = bulkSelectionState.value.allSelected;
|
||||
const hadPartialSelection = bulkSelectedIds.value.size > 0;
|
||||
|
||||
fetchResponses(page);
|
||||
|
||||
if (wasAllPageSelected || hadPartialSelection) {
|
||||
bulkSelectedIds.value = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
onPageChange(responseMeta.value.page - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const onBulkDeleteSuccess = () => {
|
||||
fetchResponseAfterBulkAction();
|
||||
};
|
||||
|
||||
const handleAssistantFilterChange = assistant => {
|
||||
selectedAssistant.value = assistant;
|
||||
fetchResponses();
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(async () => {
|
||||
fetchResponses();
|
||||
}, 500);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
fetchResponses();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:total-count="responseMeta.totalCount"
|
||||
:current-page="responseMeta.page"
|
||||
:button-policy="['administrator']"
|
||||
:header-title="$t('CAPTAIN.RESPONSES.PENDING_FAQS')"
|
||||
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!filteredResponses.length"
|
||||
:show-pagination-footer="!isFetching && !!filteredResponses.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:back-url="backUrl"
|
||||
@update:current-page="onPageChange"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<template #knowMore>
|
||||
<FeatureSpotlightPopover
|
||||
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-faq"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<ResponsePageEmptyState @click="handleCreate" />
|
||||
</template>
|
||||
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #controls>
|
||||
<div
|
||||
v-if="shouldShowDropdown"
|
||||
class="mb-4 -mt-3 flex justify-between items-center py-1"
|
||||
:class="{
|
||||
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
|
||||
bulkSelectionState.hasSelected,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="!bulkSelectionState.hasSelected"
|
||||
class="flex gap-3 justify-between w-full items-center"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
||||
class="w-64"
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
name="slide-fade"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
|
||||
enter-to-class="opacity-100 transform translate-x-0"
|
||||
leave-active-class="hidden opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="bulkSelectionState.hasSelected"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="bulkCheckbox"
|
||||
:indeterminate="bulkSelectionState.isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
|
||||
{{ buildSelectedCountLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||
{{
|
||||
$t('CAPTAIN.RESPONSES.SELECTED', {
|
||||
count: bulkSelectedIds.size,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex gap-3 items-center">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
ghost
|
||||
icon="i-lucide-check"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkApprove"
|
||||
/>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
@click="bulkDeleteDialog.dialogRef.open()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ResponseCard
|
||||
v-for="response in filteredResponses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
:answer="response.answer"
|
||||
:assistant="response.assistant"
|
||||
:documentable="response.documentable"
|
||||
:status="response.status"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
:is-selected="bulkSelectedIds.has(response.id)"
|
||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||
:show-menu="false"
|
||||
:show-actions="!bulkSelectedIds.has(response.id)"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
@select="handleCardSelect"
|
||||
@hover="isHovered => handleCardHover(isHovered, response.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DeleteDialog
|
||||
v-if="selectedResponse"
|
||||
ref="deleteDialog"
|
||||
:entity="selectedResponse"
|
||||
type="Responses"
|
||||
@delete-success="onDeleteSuccess"
|
||||
/>
|
||||
|
||||
<BulkDeleteDialog
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="Responses"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
|
||||
<CreateResponseDialog
|
||||
v-if="dialogType"
|
||||
ref="createDialog"
|
||||
:type="dialogType"
|
||||
:selected-response="selectedResponse"
|
||||
@close="handleCreateClose"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import {
|
||||
useFunctionGetter,
|
||||
useMapGetter,
|
||||
useStore,
|
||||
} from 'dashboard/composables/store';
|
||||
|
||||
import Integration from './Integration.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const integrationLoaded = ref(false);
|
||||
|
||||
const integration = useFunctionGetter('integrations/getIntegration', 'github');
|
||||
|
||||
const uiFlags = useMapGetter('integrations/getUIFlags');
|
||||
|
||||
const integrationAction = computed(() => {
|
||||
if (integration.value.enabled) {
|
||||
return 'disconnect';
|
||||
}
|
||||
return integration.value.action;
|
||||
});
|
||||
|
||||
const initializeGithubIntegration = async () => {
|
||||
await store.dispatch('integrations/get', 'github');
|
||||
integrationLoaded.value = true;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeGithubIntegration();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-grow flex-shrink max-w-6xl p-4 mx-auto overflow-auto">
|
||||
<div v-if="integrationLoaded && !uiFlags.isCreatingGithub">
|
||||
<Integration
|
||||
:integration-id="integration.id"
|
||||
:integration-logo="integration.logo"
|
||||
:integration-name="integration.name"
|
||||
:integration-description="integration.description"
|
||||
:integration-enabled="integration.enabled"
|
||||
:integration-action="integrationAction"
|
||||
:delete-confirmation-text="{
|
||||
title: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.TITLE'),
|
||||
message: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.MESSAGE'),
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center flex-1">
|
||||
<Spinner size="" color-scheme="primary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+11
-1
@@ -10,7 +10,7 @@ import SettingsContent from '../Wrapper.vue';
|
||||
import Linear from './Linear.vue';
|
||||
import Notion from './Notion.vue';
|
||||
import Shopify from './Shopify.vue';
|
||||
|
||||
import Github from './Github.vue';
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
@@ -100,6 +100,16 @@ export default {
|
||||
},
|
||||
props: route => ({ code: route.query.code }),
|
||||
},
|
||||
{
|
||||
path: 'github',
|
||||
name: 'settings_integrations_github',
|
||||
component: Github,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
props: route => ({ error: route.query.error }),
|
||||
},
|
||||
{
|
||||
path: 'shopify',
|
||||
name: 'settings_integrations_shopify',
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
import CaptainResponseAPI from 'dashboard/api/captain/response';
|
||||
import { createStore } from './storeFactory';
|
||||
|
||||
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainResponse',
|
||||
API: CaptainResponseAPI,
|
||||
getters: {
|
||||
getPendingCount: state => state.meta.pendingCount || 0,
|
||||
},
|
||||
mutations: {
|
||||
[SET_PENDING_COUNT](state, count) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
pendingCount: Number(count),
|
||||
};
|
||||
},
|
||||
},
|
||||
actions: mutations => ({
|
||||
removeBulkResponses: ({ commit, state }, ids) => {
|
||||
const updatedRecords = state.records.filter(
|
||||
@@ -41,18 +28,5 @@ export default createStore({
|
||||
|
||||
commit(mutations.SET, updatedRecords);
|
||||
},
|
||||
fetchPendingCount: async ({ commit }, assistantId) => {
|
||||
try {
|
||||
const response = await CaptainResponseAPI.get({
|
||||
status: 'pending',
|
||||
page: 1,
|
||||
assistantId,
|
||||
});
|
||||
const count = response.data?.meta?.total_count || 0;
|
||||
commit(SET_PENDING_COUNT, count);
|
||||
} catch (error) {
|
||||
commit(SET_PENDING_COUNT, 0);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -49,7 +49,6 @@ export const createMutations = mutationTypes => ({
|
||||
},
|
||||
[mutationTypes.SET_META](state, meta) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
totalCount: Number(meta.total_count),
|
||||
page: Number(meta.page),
|
||||
};
|
||||
@@ -70,7 +69,7 @@ export const createCrudActions = (API, mutationTypes) => ({
|
||||
});
|
||||
|
||||
export const createStore = options => {
|
||||
const { name, API, actions, getters, mutations } = options;
|
||||
const { name, API, actions, getters } = options;
|
||||
const mutationTypes = generateMutationTypes(name);
|
||||
|
||||
const customActions = actions ? actions(mutationTypes) : {};
|
||||
@@ -82,10 +81,7 @@ export const createStore = options => {
|
||||
...createGetters(),
|
||||
...(getters || {}),
|
||||
},
|
||||
mutations: {
|
||||
...createMutations(mutationTypes),
|
||||
...(mutations || {}),
|
||||
},
|
||||
mutations: createMutations(mutationTypes),
|
||||
actions: {
|
||||
...createCrudActions(API, mutationTypes),
|
||||
...customActions,
|
||||
|
||||
@@ -78,11 +78,6 @@ export const getters = {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out authentication templates
|
||||
if (template.category === 'AUTHENTICATION') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
|
||||
const hasUnsupportedComponents = template.components.some(
|
||||
component =>
|
||||
|
||||
@@ -265,39 +265,6 @@ describe('#getters', () => {
|
||||
expect(result[0].name).toBe('regular_template');
|
||||
});
|
||||
|
||||
it('filters out authentication templates', () => {
|
||||
const authenticationTemplates = [
|
||||
{
|
||||
name: 'auth_template',
|
||||
status: 'approved',
|
||||
category: 'AUTHENTICATION',
|
||||
components: [
|
||||
{ type: 'BODY', text: 'Your verification code is {{1}}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'regular_template',
|
||||
status: 'approved',
|
||||
category: 'MARKETING',
|
||||
components: [{ type: 'BODY', text: 'Regular message' }],
|
||||
},
|
||||
];
|
||||
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
message_templates: authenticationTemplates,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('regular_template');
|
||||
});
|
||||
|
||||
it('returns valid templates from fixture data', () => {
|
||||
const state = {
|
||||
records: [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Integrations::App
|
||||
include Linear::IntegrationHelper
|
||||
include Github::IntegrationHelper
|
||||
attr_accessor :params
|
||||
|
||||
def initialize(params)
|
||||
@@ -30,10 +31,15 @@ class Integrations::App
|
||||
params[:fields]
|
||||
end
|
||||
|
||||
# There is no way to get the account_id from the linear callback
|
||||
# so we are using the generate_linear_token method to generate a token and encode it in the state parameter
|
||||
# There is no way to get the account_id from the linear/github callback
|
||||
# so we are using the generate token method to generate a token and encode it in the state parameter
|
||||
def encode_state
|
||||
generate_linear_token(Current.account.id)
|
||||
case params[:id]
|
||||
when 'linear'
|
||||
generate_linear_token(Current.account.id)
|
||||
when 'github'
|
||||
generate_github_token(Current.account.id)
|
||||
end
|
||||
end
|
||||
|
||||
def action
|
||||
@@ -43,6 +49,8 @@ class Integrations::App
|
||||
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
|
||||
when 'linear'
|
||||
build_linear_action
|
||||
when 'github'
|
||||
build_github_action
|
||||
else
|
||||
params[:action]
|
||||
end
|
||||
@@ -54,6 +62,8 @@ class Integrations::App
|
||||
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
|
||||
when 'linear'
|
||||
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
|
||||
when 'github'
|
||||
github_enabled?
|
||||
when 'shopify'
|
||||
shopify_enabled?(account)
|
||||
when 'leadsquared'
|
||||
@@ -78,6 +88,17 @@ class Integrations::App
|
||||
].join('&')
|
||||
end
|
||||
|
||||
def build_github_action
|
||||
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
||||
|
||||
# For GitHub Apps, redirect to installation page
|
||||
# The standard /installations/new URL should show org/user selection if the app is properly configured
|
||||
# Include state parameter with signed account ID for account context
|
||||
github_app_name = GlobalConfigService.load('GITHUB_APP_NAME', 'chatwoot-qa')
|
||||
state = Current.account.to_signed_global_id(expires_in: 1.hour)
|
||||
"https://github.com/apps/#{github_app_name}/installations/new?state=#{state}"
|
||||
end
|
||||
|
||||
def enabled?(account)
|
||||
case params[:id]
|
||||
when 'webhook'
|
||||
@@ -101,6 +122,10 @@ class Integrations::App
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback"
|
||||
end
|
||||
|
||||
def self.github_integration_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/github/callback"
|
||||
end
|
||||
|
||||
class << self
|
||||
def apps
|
||||
Hashie::Mash.new(APPS_CONFIG)
|
||||
@@ -119,6 +144,10 @@ class Integrations::App
|
||||
|
||||
private
|
||||
|
||||
def github_enabled?
|
||||
GlobalConfigService.load('GITHUB_CLIENT_ID', nil).present? && GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil).present?
|
||||
end
|
||||
|
||||
def shopify_enabled?(account)
|
||||
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
|
||||
end
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
require 'agents'
|
||||
|
||||
class Captain::Assistant::BaseAssistantService
|
||||
def initialize(text:)
|
||||
@text = text
|
||||
end
|
||||
|
||||
def execute
|
||||
agent = build_agent
|
||||
runner = Agents::Runner.with_agents(agent)
|
||||
|
||||
result = runner.run(@text, context: {})
|
||||
|
||||
return error_response(result.error) if result.respond_to?(:error) && result.error
|
||||
|
||||
process_result(result)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[Captain V2] #{self.class.name} error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
error_response(e.message)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def build_agent
|
||||
Agents::Agent.new(
|
||||
name: agent_name,
|
||||
instructions: build_instructions,
|
||||
model: agent_model,
|
||||
response_schema: response_schema
|
||||
)
|
||||
end
|
||||
|
||||
def agent_model
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
|
||||
end
|
||||
|
||||
def agent_name
|
||||
raise NotImplementedError, "#{self.class} must implement agent_name"
|
||||
end
|
||||
|
||||
def build_instructions
|
||||
raise NotImplementedError, "#{self.class} must implement build_instructions"
|
||||
end
|
||||
|
||||
def response_schema
|
||||
raise NotImplementedError, "#{self.class} must implement response_schema"
|
||||
end
|
||||
|
||||
def process_result(result)
|
||||
output = result.output
|
||||
|
||||
return error_response(output[:error] || output['error']) if output.is_a?(Hash) && (output[:error] || output['error'])
|
||||
|
||||
build_success_response(output)
|
||||
end
|
||||
|
||||
def build_success_response(output)
|
||||
{
|
||||
success: true,
|
||||
result: extract_primary_field(output),
|
||||
original_text: @text
|
||||
}
|
||||
end
|
||||
|
||||
def error_response(error_message)
|
||||
{
|
||||
success: false,
|
||||
error: error_message,
|
||||
original_text: @text
|
||||
}
|
||||
end
|
||||
|
||||
def extract_field(output, *field_names)
|
||||
return output.to_s unless output.is_a?(Hash)
|
||||
|
||||
field_names.each do |field|
|
||||
value = output[field.to_sym] || output[field.to_s]
|
||||
return value if value
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def extract_primary_field(output)
|
||||
output
|
||||
end
|
||||
end
|
||||
@@ -1,56 +0,0 @@
|
||||
class Captain::Assistant::ChangeToneService < Captain::Assistant::BaseAssistantService
|
||||
SUPPORTED_TONES = %w[professional casual straightforward confident friendly].freeze
|
||||
|
||||
def initialize(text:, tone:)
|
||||
super(text: text)
|
||||
@tone = validate_tone(tone)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def agent_name
|
||||
'ToneChanger'
|
||||
end
|
||||
|
||||
def build_instructions
|
||||
context = { tone: @tone }
|
||||
Captain::PromptRenderer.render('rewrite/tone', context.with_indifferent_access)
|
||||
end
|
||||
|
||||
def response_schema
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
rewritten_text: {
|
||||
type: 'string',
|
||||
description: 'The rewritten text with the requested tone applied'
|
||||
},
|
||||
tone_applied: {
|
||||
type: 'string',
|
||||
description: 'The tone that was applied to the text'
|
||||
}
|
||||
},
|
||||
required: %w[rewritten_text tone_applied],
|
||||
additionalProperties: false
|
||||
}
|
||||
end
|
||||
|
||||
def build_success_response(output)
|
||||
{
|
||||
success: true,
|
||||
rewritten_text: extract_field(output, 'rewritten_text'),
|
||||
tone: @tone,
|
||||
original_text: @text
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_tone(tone)
|
||||
tone_str = tone.to_s.downcase
|
||||
|
||||
raise ArgumentError, "Unsupported tone: #{tone}. Supported tones: #{SUPPORTED_TONES.join(', ')}" unless SUPPORTED_TONES.include?(tone_str)
|
||||
|
||||
tone_str
|
||||
end
|
||||
end
|
||||
@@ -1,124 +0,0 @@
|
||||
class Captain::Assistant::ConversationSummaryService < Captain::Assistant::BaseAssistantService
|
||||
TOKEN_LIMIT = 16_000
|
||||
|
||||
def initialize(conversation:)
|
||||
@conversation = conversation
|
||||
super(text: format_conversation_messages)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def agent_name
|
||||
'ConversationSummarizer'
|
||||
end
|
||||
|
||||
def build_instructions
|
||||
Captain::PromptRenderer.render('summary', {})
|
||||
end
|
||||
|
||||
def response_schema
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
customer_intent: {
|
||||
type: 'string',
|
||||
description: 'Brief description of what the customer wants (around 50 words)'
|
||||
},
|
||||
conversation_summary: {
|
||||
type: 'string',
|
||||
description: 'Summary of the conversation in approximately 200 words'
|
||||
},
|
||||
action_items: {
|
||||
type: 'array',
|
||||
description: 'List of action items committed to by the agent or left incomplete',
|
||||
items: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
follow_up_items: {
|
||||
type: 'array',
|
||||
description: 'List of unresolved issues or outstanding questions',
|
||||
items: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
},
|
||||
required: %w[customer_intent conversation_summary],
|
||||
additionalProperties: false
|
||||
}
|
||||
end
|
||||
|
||||
def build_success_response(output)
|
||||
{
|
||||
success: true,
|
||||
summary: build_summary_markdown(output),
|
||||
structured_data: {
|
||||
customer_intent: extract_field(output, 'customer_intent'),
|
||||
conversation_summary: extract_field(output, 'conversation_summary'),
|
||||
action_items: extract_array_field(output, 'action_items'),
|
||||
follow_up_items: extract_array_field(output, 'follow_up_items')
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_conversation_messages
|
||||
messages = []
|
||||
character_count = 0
|
||||
|
||||
@conversation.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.reorder('id desc')
|
||||
.each do |message|
|
||||
break if character_count + message.content.length > TOKEN_LIMIT
|
||||
next if message.content.blank?
|
||||
|
||||
messages.prepend(format_message(message))
|
||||
character_count += message.content.length
|
||||
end
|
||||
|
||||
messages.join("\n")
|
||||
end
|
||||
|
||||
def format_message(message)
|
||||
sender_type = message.incoming? ? 'Customer' : 'Agent'
|
||||
sender_name = message.sender&.name || 'Unknown'
|
||||
"#{sender_type} #{sender_name}: #{message.content}"
|
||||
end
|
||||
|
||||
def extract_array_field(output, field_name)
|
||||
return [] unless output.is_a?(Hash)
|
||||
|
||||
output[field_name.to_sym] || output[field_name.to_s] || []
|
||||
end
|
||||
|
||||
def build_summary_markdown(output)
|
||||
return '' unless output.is_a?(Hash)
|
||||
|
||||
sections = []
|
||||
|
||||
if (intent = extract_field(output, 'customer_intent')).present?
|
||||
sections << "**Customer Intent**\n\n#{intent}"
|
||||
end
|
||||
|
||||
if (summary = extract_field(output, 'conversation_summary')).present?
|
||||
sections << "**Conversation Summary**\n\n#{summary}"
|
||||
end
|
||||
|
||||
action_items = extract_array_field(output, 'action_items')
|
||||
if action_items&.any?
|
||||
items_list = action_items.map { |item| "- #{item}" }.join("\n")
|
||||
sections << "**Action Items**\n\n#{items_list}"
|
||||
end
|
||||
|
||||
follow_up_items = extract_array_field(output, 'follow_up_items')
|
||||
if follow_up_items&.any?
|
||||
items_list = follow_up_items.map { |item| "- #{item}" }.join("\n")
|
||||
sections << "**Follow-up Items**\n\n#{items_list}"
|
||||
end
|
||||
|
||||
sections.join("\n\n")
|
||||
end
|
||||
end
|
||||
@@ -1,49 +0,0 @@
|
||||
class Captain::Assistant::FixGrammarService < Captain::Assistant::BaseAssistantService
|
||||
protected
|
||||
|
||||
def agent_name
|
||||
'GrammarFixer'
|
||||
end
|
||||
|
||||
def build_instructions
|
||||
Captain::PromptRenderer.render('rewrite/grammar', {})
|
||||
end
|
||||
|
||||
def response_schema
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
corrected_text: {
|
||||
type: 'string',
|
||||
description: 'The text with corrected grammar, spelling, and punctuation'
|
||||
},
|
||||
corrections_made: {
|
||||
type: 'array',
|
||||
description: 'List of corrections that were made',
|
||||
items: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['corrected_text'],
|
||||
additionalProperties: false
|
||||
}
|
||||
end
|
||||
|
||||
def build_success_response(output)
|
||||
{
|
||||
success: true,
|
||||
corrected_text: extract_field(output, 'corrected_text'),
|
||||
corrections_made: extract_corrections(output),
|
||||
original_text: @text
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_corrections(output)
|
||||
return [] unless output.is_a?(Hash)
|
||||
|
||||
output[:corrections_made] || output['corrections_made'] || []
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,60 @@
|
||||
class Github::GithubService
|
||||
attr_reader :hook
|
||||
|
||||
def initialize(hook:)
|
||||
@hook = hook
|
||||
end
|
||||
|
||||
def repositories
|
||||
client.repositories(nil, type: 'all')
|
||||
rescue Octokit::Error => e
|
||||
Rails.logger.error("GitHub API error fetching repositories: #{e.message}")
|
||||
raise_api_error(e)
|
||||
end
|
||||
|
||||
def assignees(repo_full_name)
|
||||
client.repository_assignees(repo_full_name)
|
||||
rescue Octokit::Error => e
|
||||
Rails.logger.error("GitHub API error fetching assignees for #{repo_full_name}: #{e.message}")
|
||||
raise_api_error(e)
|
||||
end
|
||||
|
||||
def search_repositories(query)
|
||||
return repositories if query.blank?
|
||||
|
||||
# Get all user's repositories first
|
||||
user_repos = repositories
|
||||
|
||||
# Filter repositories by query string (case-insensitive)
|
||||
user_repos.select do |repo|
|
||||
repo.full_name.downcase.include?(query.downcase) ||
|
||||
(repo.description&.downcase&.include?(query.downcase))
|
||||
end
|
||||
rescue Octokit::Error => e
|
||||
Rails.logger.error("GitHub API error searching repositories with query '#{query}': #{e.message}")
|
||||
raise_api_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def client
|
||||
@client ||= Octokit::Client.new(
|
||||
access_token: hook.access_token,
|
||||
auto_paginate: true,
|
||||
per_page: 100
|
||||
)
|
||||
end
|
||||
|
||||
def raise_api_error(error)
|
||||
case error.response_status
|
||||
when 401
|
||||
raise CustomExceptions::Base.new('GitHub authentication failed', 401)
|
||||
when 403
|
||||
raise CustomExceptions::Base.new('GitHub API rate limit exceeded or insufficient permissions', 403)
|
||||
when 404
|
||||
raise CustomExceptions::Base.new('GitHub resource not found', 404)
|
||||
else
|
||||
raise CustomExceptions::Base.new("GitHub API error: #{error.message}", error.response_status || 500)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -169,7 +169,11 @@
|
||||
<symbol id="icon-shopify" viewBox="0 0 32 32">
|
||||
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
|
||||
</symbol>
|
||||
|
||||
|
||||
<symbol id="icon-github" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-slack" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
|
||||
</symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
@@ -176,6 +176,9 @@
|
||||
- name: notion_integration
|
||||
display_name: Notion Integration
|
||||
enabled: false
|
||||
- name: github_integration
|
||||
display_name: Github Integration
|
||||
enabled: false
|
||||
- name: captain_integration_v2
|
||||
display_name: Captain V2
|
||||
enabled: false
|
||||
|
||||
@@ -451,3 +451,22 @@
|
||||
locked: false
|
||||
description: 'Token expiry in days'
|
||||
## ------ End of Customizations for Customers ------ ##
|
||||
|
||||
# ------- Github Integration Config ------- #
|
||||
- name: GITHUB_CLIENT_ID
|
||||
display_title: 'Github Client ID'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Github client ID'
|
||||
- name: GITHUB_CLIENT_SECRET
|
||||
display_title: 'Github Client Secret'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Github client secret'
|
||||
type: secret
|
||||
- name: GITHUB_APP_NAME
|
||||
display_title: 'Github App Name'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Github App name for installation URLs (e.g., "chatwoot-qa")'
|
||||
## ------ End of Github Integration Config ------- #
|
||||
|
||||
@@ -284,3 +284,11 @@ leadsquared:
|
||||
'conversation_activity_score',
|
||||
'transcript_activity_score',
|
||||
]
|
||||
|
||||
github:
|
||||
id: github
|
||||
logo: github.png
|
||||
i18n_key: github
|
||||
hook_type: account
|
||||
action: https://github.com/login/oauth/authorize
|
||||
allow_multiple_hooks: false
|
||||
|
||||
@@ -312,6 +312,10 @@ en:
|
||||
name: 'LeadSquared'
|
||||
short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
|
||||
description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
|
||||
github:
|
||||
name: 'GitHub'
|
||||
short_description: 'Create and manage GitHub issues directly from conversations.'
|
||||
description: 'Connect your GitHub repositories to create issues directly from customer conversations. This integration helps you track bugs, feature requests, and development tasks seamlessly.'
|
||||
captain:
|
||||
copilot_message_required: Message is required
|
||||
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
|
||||
|
||||
@@ -302,6 +302,14 @@ Rails.application.routes.draw do
|
||||
delete :destroy
|
||||
end
|
||||
end
|
||||
resource :github, controller: 'github', only: [] do
|
||||
collection do
|
||||
delete :destroy
|
||||
get :repositories
|
||||
get :search_repositories
|
||||
get 'repositories/:owner/:repo/assignees', to: 'github#assignees', as: :repository_assignees
|
||||
end
|
||||
end
|
||||
end
|
||||
resources :working_hours, only: [:update]
|
||||
|
||||
@@ -549,6 +557,10 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
namespace :github do
|
||||
get :callback, to: 'callbacks#show'
|
||||
end
|
||||
|
||||
get 'microsoft/callback', to: 'microsoft/callbacks#show'
|
||||
get 'google/callback', to: 'google/callbacks#show'
|
||||
get 'instagram/callback', to: 'instagram/callbacks#show'
|
||||
|
||||
@@ -5,13 +5,11 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
|
||||
assistant = Captain::Assistant.find(assistant_id)
|
||||
metadata = payload[:metadata]
|
||||
|
||||
canonical_url = normalize_link(metadata['url'])
|
||||
document = assistant.documents.find_or_initialize_by(
|
||||
external_link: canonical_url
|
||||
external_link: metadata['url']
|
||||
)
|
||||
|
||||
document.update!(
|
||||
external_link: canonical_url,
|
||||
content: payload[:markdown],
|
||||
name: metadata['title'],
|
||||
status: :available
|
||||
@@ -19,10 +17,4 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
|
||||
rescue StandardError => e
|
||||
raise "Failed to parse FireCrawl data: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_link(raw_url)
|
||||
raw_url.to_s.delete_suffix('/')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,11 +15,11 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
|
||||
page_title = crawler.page_title || ''
|
||||
content = crawler.body_text_content || ''
|
||||
|
||||
normalized_link = normalize_link(page_link)
|
||||
document = assistant.documents.find_or_initialize_by(external_link: normalized_link)
|
||||
document = assistant.documents.find_or_initialize_by(
|
||||
external_link: page_link
|
||||
)
|
||||
|
||||
document.update!(
|
||||
external_link: normalized_link,
|
||||
name: page_title[0..254], content: content[0..14_999], status: :available
|
||||
)
|
||||
rescue StandardError => e
|
||||
@@ -28,10 +28,6 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def normalize_link(raw_link)
|
||||
raw_link.to_s.delete_suffix('/')
|
||||
end
|
||||
|
||||
def limit_exceeded?(account)
|
||||
limits = account.usage_limits[:captain][:documents]
|
||||
limits[:current_available].negative? || limits[:current_available].zero?
|
||||
|
||||
@@ -37,7 +37,6 @@ class Captain::Document < ApplicationRecord
|
||||
validate :validate_file_attachment, if: -> { pdf_file.attached? }
|
||||
before_validation :ensure_account_id
|
||||
before_validation :set_external_link_for_pdf
|
||||
before_validation :normalize_external_link
|
||||
|
||||
enum status: {
|
||||
in_progress: 0,
|
||||
@@ -48,7 +47,7 @@ class Captain::Document < ApplicationRecord
|
||||
after_create_commit :enqueue_crawl_job
|
||||
after_create_commit :update_document_usage
|
||||
after_destroy :update_document_usage
|
||||
after_commit :enqueue_response_builder_job
|
||||
after_commit :enqueue_response_builder_job, on: :update, if: :should_enqueue_response_builder?
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
||||
@@ -95,18 +94,15 @@ class Captain::Document < ApplicationRecord
|
||||
end
|
||||
|
||||
def enqueue_response_builder_job
|
||||
return unless should_enqueue_response_builder?
|
||||
return if status != 'available'
|
||||
|
||||
Captain::Documents::ResponseBuilderJob.perform_later(self)
|
||||
end
|
||||
|
||||
def should_enqueue_response_builder?
|
||||
return false if destroyed?
|
||||
return false unless available?
|
||||
|
||||
return saved_change_to_status? if pdf_document?
|
||||
|
||||
(saved_change_to_status? || saved_change_to_content?) && content.present?
|
||||
# Only enqueue when status changes to available
|
||||
# Avoid re-enqueueing when metadata is updated by the job itself
|
||||
saved_change_to_status? && status == 'available'
|
||||
end
|
||||
|
||||
def update_document_usage
|
||||
@@ -144,11 +140,4 @@ class Captain::Document < ApplicationRecord
|
||||
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
|
||||
self.external_link = "PDF: #{pdf_file.filename.base}_#{timestamp}"
|
||||
end
|
||||
|
||||
def normalize_external_link
|
||||
return if external_link.blank?
|
||||
return if pdf_document?
|
||||
|
||||
self.external_link = external_link.delete_suffix('/')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
require 'liquid'
|
||||
|
||||
class Captain::PromptRenderer
|
||||
class << self
|
||||
def render(template_name, context = {})
|
||||
template = load_template(template_name)
|
||||
liquid_template = Liquid::Template.parse(template)
|
||||
liquid_template.render(stringify_keys(context))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_template(template_name)
|
||||
template_path = Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid")
|
||||
|
||||
raise "Template not found: #{template_name}" unless File.exist?(template_path)
|
||||
|
||||
File.read(template_path)
|
||||
end
|
||||
|
||||
def stringify_keys(hash)
|
||||
hash.deep_stringify_keys
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,9 +0,0 @@
|
||||
module Enterprise::Captain::PromptRenderer
|
||||
private
|
||||
|
||||
def candidate_paths(template_name)
|
||||
[
|
||||
Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid")
|
||||
] + super
|
||||
end
|
||||
end
|
||||
@@ -1,35 +0,0 @@
|
||||
require 'liquid'
|
||||
|
||||
class Captain::PromptRenderer
|
||||
class << self
|
||||
def render(template_name, context = {})
|
||||
template = load_template(template_name)
|
||||
liquid_template = Liquid::Template.parse(template)
|
||||
liquid_template.render(stringify_keys(context))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_template(template_name)
|
||||
candidate_paths(template_name).each do |template_path|
|
||||
next unless File.exist?(template_path)
|
||||
|
||||
return File.read(template_path)
|
||||
end
|
||||
|
||||
raise "Template not found: #{template_name}"
|
||||
end
|
||||
|
||||
def candidate_paths(template_name)
|
||||
[
|
||||
Rails.root.join('lib', 'captain', 'prompts', "#{template_name}.liquid")
|
||||
]
|
||||
end
|
||||
|
||||
def stringify_keys(hash)
|
||||
hash.deep_stringify_keys
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Captain::PromptRenderer.singleton_class.prepend_mod_with('Captain::PromptRenderer')
|
||||
@@ -1,8 +0,0 @@
|
||||
You are an expert writing editor. Rewrite the provided text to correct grammar, spelling, and punctuation while fully preserving the original meaning and style.
|
||||
|
||||
Rules:
|
||||
• Preserve tone, language, formatting, structure, proper nouns, URLs, quotes, numbers, IDs, addresses.
|
||||
• Do not modify content inside code blocks or backticks.
|
||||
• Do not add content or opinions. Keep length similar.
|
||||
• Minimal edits if already correct.
|
||||
• Do not alter legal, medical, or compliance meaning.
|
||||
@@ -1,17 +0,0 @@
|
||||
You are an expert writing editor. Rewrite the provided text according to the selected tone while keeping the original intent.
|
||||
|
||||
{% case tone %}
|
||||
{% when "professional" %}Rewrite in a formal and precise tone.
|
||||
{% when "casual" %}Rewrite in a relaxed and conversational tone.
|
||||
{% when "straightforward" %}Rewrite concise and direct. Remove filler.
|
||||
{% when "confident" %}Rewrite bold and assured.
|
||||
{% when "friendly" %}Rewrite warm and positive.
|
||||
{% else %}Rewrite to improve clarity and smoothness.
|
||||
{% endcase %}
|
||||
|
||||
Rules:
|
||||
• Preserve original language, meaning, formatting, structure, proper nouns, URLs, quotes, numbers, IDs, addresses.
|
||||
• Do not modify content inside code blocks or backticks.
|
||||
• Do not add content or opinions. Keep length similar.
|
||||
• Minimal edits if already correct.
|
||||
• Do not alter legal, medical, or compliance meaning.
|
||||
@@ -1,19 +0,0 @@
|
||||
As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
|
||||
|
||||
Make sure you strongly adhere to the following rules when generating the summary:
|
||||
|
||||
1. Be brief and concise. The shorter the summary the better.
|
||||
2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
|
||||
3. Describe the customer intent in around 50 words.
|
||||
4. Remove information that is not directly relevant to the customer's problem or the agent's solution. For example, personal anecdotes, small talk, etc.
|
||||
5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell.
|
||||
6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
|
||||
7. 'Action Items' should strictly encapsulate tasks committed to by the agent or left incomplete. Any suggestions made by the agent should not be included.
|
||||
8. The 'Action Items' should be brief and concise.
|
||||
9. Mark important words or parts of sentences as bold.
|
||||
10. Apply markdown syntax to format any included code, using backticks.
|
||||
11. Include a section for "Follow-up Items" or "Open Questions" if there are any unresolved issues or outstanding questions.
|
||||
12. If any section does not have any content, remove that section and the heading from the response.
|
||||
13. Do not insert your own opinions about the conversation.
|
||||
|
||||
Reply in the user's language.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,165 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Github::CallbacksController, type: :controller do
|
||||
let(:account) { create(:account) }
|
||||
let(:signed_account_id) { account.to_signed_global_id(expires_in: 1.hour) }
|
||||
|
||||
before do
|
||||
# Clear any existing GitHub hooks for the account
|
||||
account.hooks.where(app_id: 'github').destroy_all
|
||||
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('test_client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('test_client_secret')
|
||||
end
|
||||
|
||||
describe 'route accessibility' do
|
||||
it 'is accessible via the github callback route' do
|
||||
expect(get: '/github/callback').to route_to(
|
||||
controller: 'github/callbacks',
|
||||
action: 'show'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'helper methods' do
|
||||
describe '#account' do
|
||||
it 'resolves account from valid signed global id' do
|
||||
controller.params = { state: signed_account_id }
|
||||
expect(controller.send(:account)).to eq(account)
|
||||
end
|
||||
|
||||
it 'raises error for missing state' do
|
||||
controller.params = {}
|
||||
expect { controller.send(:account) }.to raise_error(ActionController::BadRequest, 'Invalid account context')
|
||||
end
|
||||
|
||||
it 'raises error for invalid state' do
|
||||
controller.params = { state: 'invalid_state' }
|
||||
expect { controller.send(:account) }.to raise_error(ActionController::BadRequest, 'Invalid account context')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#oauth_client' do
|
||||
it 'creates OAuth2 client with correct configuration' do
|
||||
oauth_client = controller.send(:oauth_client)
|
||||
expect(oauth_client).to be_a(OAuth2::Client)
|
||||
expect(oauth_client.id).to eq('test_client_id')
|
||||
expect(oauth_client.secret).to eq('test_client_secret')
|
||||
expect(oauth_client.site).to eq('https://github.com')
|
||||
expect(oauth_client.options[:token_url]).to eq('/login/oauth/access_token')
|
||||
expect(oauth_client.options[:authorize_url]).to eq('/login/oauth/authorize')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#github_redirect_uri' do
|
||||
before do
|
||||
allow(controller).to receive(:account).and_return(account)
|
||||
end
|
||||
|
||||
it 'generates correct GitHub redirect URI' do
|
||||
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
|
||||
uri = controller.send(:github_redirect_uri)
|
||||
expect(uri).to eq("http://localhost:3000/app/accounts/#{account.id}/settings/integrations/github")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fallback_redirect_uri' do
|
||||
it 'generates fallback redirect URI when account is unavailable' do
|
||||
allow(controller).to receive(:account).and_raise(StandardError)
|
||||
|
||||
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
|
||||
uri = controller.send(:fallback_redirect_uri)
|
||||
expect(uri).to eq('http://localhost:3000/app/settings/integrations')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_hook_settings' do
|
||||
let(:mock_parsed_body) do
|
||||
{
|
||||
'access_token' => 'test_token',
|
||||
'token_type' => 'bearer',
|
||||
'scope' => 'repo,read:org'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:parsed_body).and_return(mock_parsed_body)
|
||||
end
|
||||
|
||||
it 'builds hook settings with installation_id' do
|
||||
settings = controller.send(:build_hook_settings, '12345')
|
||||
expect(settings).to include(
|
||||
token_type: 'bearer',
|
||||
scope: 'repo,read:org',
|
||||
installation_id: '12345'
|
||||
)
|
||||
end
|
||||
|
||||
it 'builds hook settings without installation_id when nil' do
|
||||
settings = controller.send(:build_hook_settings, nil)
|
||||
expect(settings).to include(
|
||||
token_type: 'bearer',
|
||||
scope: 'repo,read:org'
|
||||
)
|
||||
expect(settings).not_to have_key(:installation_id)
|
||||
end
|
||||
|
||||
it 'removes nil installation_id values' do
|
||||
settings = controller.send(:build_hook_settings, nil)
|
||||
expect(settings.keys).not_to include(:installation_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_integration_hook' do
|
||||
let(:settings) do
|
||||
{
|
||||
token_type: 'bearer',
|
||||
scope: 'repo,read:org',
|
||||
installation_id: '12345'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:account).and_return(account)
|
||||
allow(controller).to receive(:parsed_body).and_return({
|
||||
'access_token' => 'test_token'
|
||||
})
|
||||
end
|
||||
|
||||
it 'creates integration hook with correct attributes' do
|
||||
hook = controller.send(:create_integration_hook, settings)
|
||||
expect(hook.access_token).to eq('test_token')
|
||||
expect(hook.status).to eq('enabled')
|
||||
expect(hook.app_id).to eq('github')
|
||||
expect(hook.settings).to eq(settings.stringify_keys)
|
||||
expect(hook.account).to eq(account)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_oauth_url' do
|
||||
it 'builds OAuth URL with installation context' do
|
||||
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
|
||||
url = controller.send(:build_oauth_url, '12345')
|
||||
expect(url).to include('github?setup_action=install&installation_id=12345')
|
||||
expect(controller.session[:github_installation_id]).to eq('12345')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#base_url' do
|
||||
it 'returns FRONTEND_URL when set' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(controller.send(:base_url)).to eq('https://app.chatwoot.com')
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns default localhost when FRONTEND_URL not set' do
|
||||
with_modified_env FRONTEND_URL: nil do
|
||||
expect(controller.send(:base_url)).to eq('http://localhost:3000')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -23,7 +23,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
expect(document).to have_attributes(
|
||||
content: payload[:markdown],
|
||||
name: payload[:metadata]['title'],
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
external_link: payload[:metadata]['url'],
|
||||
status: 'available'
|
||||
)
|
||||
end
|
||||
@@ -32,7 +32,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
existing_document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
account: assistant.account,
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
external_link: payload[:metadata]['url'],
|
||||
content: 'old content',
|
||||
name: 'old title',
|
||||
status: :in_progress)
|
||||
@@ -42,9 +42,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
end.not_to change(assistant.documents, :count)
|
||||
|
||||
existing_document.reload
|
||||
# Payload URL ends with '/', but we persist the canonical URL without it.
|
||||
expect(existing_document).to have_attributes(
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
content: payload[:markdown],
|
||||
name: payload[:metadata]['title'],
|
||||
status: 'available'
|
||||
|
||||
@@ -3,7 +3,7 @@ require 'rails_helper'
|
||||
RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
describe '#perform' do
|
||||
let(:assistant) { create(:captain_assistant) }
|
||||
let(:page_link) { 'https://example.com/page/' }
|
||||
let(:page_link) { 'https://example.com/page' }
|
||||
let(:page_title) { 'Example Page Title' }
|
||||
let(:content) { 'Some page content here' }
|
||||
let(:crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
|
||||
@@ -24,7 +24,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
end.to change(assistant.documents, :count).by(1)
|
||||
|
||||
document = assistant.documents.last
|
||||
expect(document.external_link).to eq('https://example.com/page')
|
||||
expect(document.external_link).to eq(page_link)
|
||||
expect(document.name).to eq(page_title)
|
||||
expect(document.content).to eq(content)
|
||||
expect(document.status).to eq('available')
|
||||
@@ -33,7 +33,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
it 'updates existing document if one exists' do
|
||||
existing_document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
external_link: 'https://example.com/page',
|
||||
external_link: page_link,
|
||||
name: 'Old Title',
|
||||
content: 'Old content')
|
||||
|
||||
|
||||
+17
-35
@@ -1,24 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::PromptRenderer do
|
||||
let(:template_name) { 'test_template' }
|
||||
let(:template_content) { 'Hello {{name}}, your balance is {{balance}}' }
|
||||
let(:enterprise_template_path) { Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid") }
|
||||
let(:oss_template_path) { Rails.root.join('lib', 'captain', 'prompts', "#{template_name}.liquid") }
|
||||
let(:template_path) { Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid") }
|
||||
let(:context) { { name: 'John', balance: 100 } }
|
||||
|
||||
before do
|
||||
if defined?(Enterprise::Captain::PromptRenderer) &&
|
||||
!Captain::PromptRenderer.singleton_class.ancestors.include?(Enterprise::Captain::PromptRenderer)
|
||||
Captain::PromptRenderer.singleton_class.prepend(Enterprise::Captain::PromptRenderer)
|
||||
end
|
||||
|
||||
allow(File).to receive(:exist?).and_return(false)
|
||||
allow(File).to receive(:read)
|
||||
|
||||
allow(File).to receive(:exist?).with(enterprise_template_path).and_return(false)
|
||||
allow(File).to receive(:exist?).with(oss_template_path).and_return(true)
|
||||
allow(File).to receive(:read).with(oss_template_path).and_return(template_content)
|
||||
allow(File).to receive(:exist?).with(template_path).and_return(true)
|
||||
allow(File).to receive(:read).with(template_path).and_return(template_content)
|
||||
end
|
||||
|
||||
describe '.render' do
|
||||
@@ -46,7 +39,7 @@ RSpec.describe Captain::PromptRenderer do
|
||||
nested_template = 'User: {{user.name}}, Account: {{user.account.type}}'
|
||||
nested_context = { user: { name: 'Alice', account: { type: 'premium' } } }
|
||||
|
||||
allow(File).to receive(:read).with(oss_template_path).and_return(nested_template)
|
||||
allow(File).to receive(:read).with(template_path).and_return(nested_template)
|
||||
|
||||
result = described_class.render(template_name, nested_context)
|
||||
|
||||
@@ -55,7 +48,7 @@ RSpec.describe Captain::PromptRenderer do
|
||||
|
||||
it 'handles empty context' do
|
||||
simple_template = 'Hello World'
|
||||
allow(File).to receive(:read).with(oss_template_path).and_return(simple_template)
|
||||
allow(File).to receive(:read).with(template_path).and_return(simple_template)
|
||||
|
||||
result = described_class.render(template_name, {})
|
||||
|
||||
@@ -72,41 +65,30 @@ RSpec.describe Captain::PromptRenderer do
|
||||
expect(result).to eq('rendered')
|
||||
expect(Liquid::Template).to have_received(:parse).with(template_content)
|
||||
end
|
||||
|
||||
it 'prefers enterprise template when available' do
|
||||
allow(File).to receive(:exist?).with(enterprise_template_path).and_return(true)
|
||||
allow(File).to receive(:read).with(enterprise_template_path).and_return('Enterprise {{name}}')
|
||||
|
||||
result = described_class.render(template_name, context)
|
||||
|
||||
expect(result).to eq('Enterprise John')
|
||||
expect(File).to have_received(:read).with(enterprise_template_path)
|
||||
expect(File).not_to have_received(:read).with(oss_template_path)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.load_template' do
|
||||
it 'reads template file from OSS path when enterprise file absent' do
|
||||
it 'reads template file from correct path' do
|
||||
described_class.send(:load_template, template_name)
|
||||
|
||||
expect(File).to have_received(:read).with(oss_template_path)
|
||||
expect(File).to have_received(:read).with(template_path)
|
||||
end
|
||||
|
||||
it 'raises error when template does not exist in either location' do
|
||||
allow(File).to receive(:exist?).with(oss_template_path).and_return(false)
|
||||
it 'raises error when template does not exist' do
|
||||
allow(File).to receive(:exist?).with(template_path).and_return(false)
|
||||
|
||||
expect { described_class.send(:load_template, template_name) }
|
||||
.to raise_error("Template not found: #{template_name}")
|
||||
end
|
||||
|
||||
it 'checks enterprise path before OSS path' do
|
||||
allow(File).to receive(:exist?).with(enterprise_template_path).and_return(true)
|
||||
allow(File).to receive(:read).with(enterprise_template_path).and_return('enterprise')
|
||||
it 'constructs correct template path' do
|
||||
expected_path = Rails.root.join('enterprise/lib/captain/prompts/my_template.liquid')
|
||||
allow(File).to receive(:exist?).with(expected_path).and_return(true)
|
||||
allow(File).to receive(:read).with(expected_path).and_return('test content')
|
||||
|
||||
described_class.send(:load_template, template_name)
|
||||
described_class.send(:load_template, 'my_template')
|
||||
|
||||
expect(File).to have_received(:exist?).with(enterprise_template_path)
|
||||
expect(File).not_to have_received(:read).with(oss_template_path)
|
||||
expect(File).to have_received(:exist?).with(expected_path)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,17 +4,6 @@ RSpec.describe Captain::Document, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
describe 'URL normalization' do
|
||||
it 'removes a trailing slash before validation' do
|
||||
document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
external_link: 'https://example.com/path/')
|
||||
|
||||
expect(document.external_link).to eq('https://example.com/path')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PDF support' do
|
||||
let(:pdf_document) do
|
||||
doc = build(:captain_document, assistant: assistant, account: account)
|
||||
@@ -93,161 +82,4 @@ RSpec.describe Captain::Document, type: :model do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'response builder job callback' do
|
||||
before { clear_enqueued_jobs }
|
||||
|
||||
describe 'non-PDF documents' do
|
||||
it 'enqueues when created with available status and content' do
|
||||
expect do
|
||||
create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when created available without content' do
|
||||
expect do
|
||||
create(:captain_document, assistant: assistant, account: account, status: :available, content: nil)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when status transitions to available with existing content' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when status transitions to available without content' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :in_progress,
|
||||
content: nil
|
||||
)
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when content is populated on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: nil
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Fresh content from crawl')
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when content changes on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: 'Initial content'
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Updated crawl content')
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when content is cleared on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: 'Initial content'
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: nil)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue for metadata-only updates' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(metadata: { 'title' => 'Updated Again' })
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue while document remains in progress' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
|
||||
|
||||
expect do
|
||||
document.update!(metadata: { 'title' => 'Updated' })
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PDF documents' do
|
||||
def build_pdf_document(status:, content:)
|
||||
build(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: status,
|
||||
content: content
|
||||
).tap do |doc|
|
||||
doc.pdf_file.attach(
|
||||
io: StringIO.new('PDF content'),
|
||||
filename: 'sample.pdf',
|
||||
content_type: 'application/pdf'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'enqueues when created available without content' do
|
||||
document = build_pdf_document(status: :available, content: nil)
|
||||
|
||||
expect do
|
||||
document.save!
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when status transitions to available' do
|
||||
document = build_pdf_document(status: :in_progress, content: nil)
|
||||
document.save!
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when content updates without status change' do
|
||||
document = build_pdf_document(status: :available, content: nil)
|
||||
document.save!
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Extracted PDF text')
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not enqueue when the document is destroyed' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.destroy!
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,6 +38,25 @@ RSpec.describe Integrations::App do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the app is github' do
|
||||
let(:app_name) { 'github' }
|
||||
|
||||
it 'returns the GitHub App installation URL with state parameter' do
|
||||
with_modified_env GITHUB_CLIENT_ID: 'dummy_client_id', GITHUB_APP_NAME: 'test-app' do
|
||||
expect(app.action).to include('https://github.com/apps/test-app/installations/new')
|
||||
expect(app.action).to include('state=')
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses default app name when GITHUB_APP_NAME is not set' do
|
||||
with_modified_env GITHUB_CLIENT_ID: 'dummy_client_id' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('dummy_client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_NAME', 'chatwoot-qa').and_return('chatwoot-qa')
|
||||
expect(app.action).to include('https://github.com/apps/chatwoot-qa/installations/new')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
@@ -87,6 +106,34 @@ RSpec.describe Integrations::App do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the app is github' do
|
||||
let(:app_name) { 'github' }
|
||||
|
||||
it 'returns false if github credentials are not present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return(nil)
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return(nil)
|
||||
expect(app.active?(account)).to be false
|
||||
end
|
||||
|
||||
it 'returns false if only client_id is present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return(nil)
|
||||
expect(app.active?(account)).to be false
|
||||
end
|
||||
|
||||
it 'returns false if only client_secret is present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return(nil)
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('client_secret')
|
||||
expect(app.active?(account)).to be false
|
||||
end
|
||||
|
||||
it 'returns true if both client_id and client_secret are present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('client_secret')
|
||||
expect(app.active?(account)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when other apps are queried' do
|
||||
let(:app_name) { 'webhook' }
|
||||
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Assistant::ChangeToneService do
|
||||
let(:text) { 'Hey! Just checking if you got my email about the project' }
|
||||
let(:tone) { 'professional' }
|
||||
let(:service) { described_class.new(text: text, tone: tone) }
|
||||
let(:agent) { instance_double(Agents::Agent) }
|
||||
let(:runner) { instance_double(Agents::Runner) }
|
||||
let(:result) do
|
||||
instance_double(Agents::RunResult,
|
||||
output: {
|
||||
'rewritten_text' => 'Good afternoon. I am following up regarding my previous email about the project.',
|
||||
'tone_applied' => 'professional'
|
||||
},
|
||||
error: nil)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
|
||||
allow(Agents::Agent).to receive(:new).and_return(agent)
|
||||
allow(Agents::Runner).to receive(:with_agents).and_return(runner)
|
||||
allow(runner).to receive(:run).with(anything, context: anything).and_return(result)
|
||||
allow(Captain::PromptRenderer).to receive(:render).and_return('tone prompt')
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
it 'initializes with text and tone' do
|
||||
expect(service.instance_variable_get(:@text)).to eq(text)
|
||||
expect(service.instance_variable_get(:@tone)).to eq('professional')
|
||||
end
|
||||
|
||||
it 'converts tone to lowercase' do
|
||||
service = described_class.new(text: text, tone: 'PROFESSIONAL')
|
||||
expect(service.instance_variable_get(:@tone)).to eq('professional')
|
||||
end
|
||||
|
||||
context 'with invalid tone' do
|
||||
it 'raises ArgumentError' do
|
||||
expect do
|
||||
described_class.new(text: text, tone: 'invalid_tone')
|
||||
end.to raise_error(ArgumentError, /Unsupported tone: invalid_tone/)
|
||||
end
|
||||
|
||||
it 'includes supported tones in error message' do
|
||||
expect do
|
||||
described_class.new(text: text, tone: 'invalid')
|
||||
end.to raise_error(ArgumentError, /professional, casual, straightforward, confident, friendly/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when successful' do
|
||||
it 'renders the tone prompt with correct context' do
|
||||
expect(Captain::PromptRenderer).to receive(:render).with(
|
||||
'rewrite/tone',
|
||||
hash_including(tone: 'professional')
|
||||
)
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'builds agent with correct parameters' do
|
||||
expect(Agents::Agent).to receive(:new).with(
|
||||
name: 'ToneChanger',
|
||||
instructions: 'tone prompt',
|
||||
model: 'gpt-4o-mini',
|
||||
response_schema: service.send(:response_schema)
|
||||
)
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'returns success response with rewritten text' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be true
|
||||
expect(response[:rewritten_text]).to eq('Good afternoon. I am following up regarding my previous email about the project.')
|
||||
expect(response[:tone]).to eq('professional')
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different tones' do
|
||||
%w[professional casual straightforward confident friendly].each do |test_tone|
|
||||
it "works with #{test_tone} tone" do
|
||||
service = described_class.new(text: text, tone: test_tone)
|
||||
allow(Agents::Agent).to receive(:new).and_return(agent)
|
||||
allow(Agents::Runner).to receive(:with_agents).and_return(runner)
|
||||
allow(runner).to receive(:run).with(anything, context: anything).and_return(result)
|
||||
|
||||
expect(Captain::PromptRenderer).to receive(:render).with(
|
||||
'rewrite/tone',
|
||||
hash_including(tone: test_tone)
|
||||
)
|
||||
service.execute
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent returns an error' do
|
||||
let(:result) { instance_double(Agents::RunResult, output: { error: 'Model error' }, error: nil) }
|
||||
|
||||
it 'returns error response' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be false
|
||||
expect(response[:error]).to eq('Model error')
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when exception is raised' do
|
||||
before do
|
||||
allow(runner).to receive(:run).with(anything, context: anything).and_raise(StandardError.new('API timeout'))
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with(/ChangeToneService error: API timeout/)
|
||||
expect(Rails.logger).to receive(:error).with(anything)
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'returns error response' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be false
|
||||
expect(response[:error]).to eq('API timeout')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_name' do
|
||||
it 'returns ToneChanger' do
|
||||
expect(service.send(:agent_name)).to eq('ToneChanger')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_instructions' do
|
||||
it 'renders the tone template with tone context' do
|
||||
expect(Captain::PromptRenderer).to receive(:render).with(
|
||||
'rewrite/tone',
|
||||
hash_including(tone: 'professional')
|
||||
)
|
||||
service.send(:build_instructions)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#response_schema' do
|
||||
let(:schema) { service.send(:response_schema) }
|
||||
|
||||
it 'defines object type' do
|
||||
expect(schema[:type]).to eq('object')
|
||||
end
|
||||
|
||||
it 'includes rewritten_text property' do
|
||||
expect(schema[:properties][:rewritten_text]).to include(
|
||||
type: 'string',
|
||||
description: 'The rewritten text with the requested tone applied'
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes tone_applied property' do
|
||||
expect(schema[:properties][:tone_applied]).to include(
|
||||
type: 'string',
|
||||
description: 'The tone that was applied to the text'
|
||||
)
|
||||
end
|
||||
|
||||
it 'marks both fields as required' do
|
||||
expect(schema[:required]).to match_array(%w[rewritten_text tone_applied])
|
||||
end
|
||||
|
||||
it 'disallows additional properties' do
|
||||
expect(schema[:additionalProperties]).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_success_response' do
|
||||
let(:output) do
|
||||
{
|
||||
'rewritten_text' => 'Rewritten content',
|
||||
'tone_applied' => 'professional'
|
||||
}
|
||||
end
|
||||
|
||||
it 'extracts rewritten_text from output' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:rewritten_text]).to eq('Rewritten content')
|
||||
end
|
||||
|
||||
it 'includes the requested tone' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:tone]).to eq('professional')
|
||||
end
|
||||
|
||||
it 'includes original text' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
|
||||
it 'marks response as successful' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:success]).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'SUPPORTED_TONES constant' do
|
||||
it 'includes all valid tones' do
|
||||
expect(described_class::SUPPORTED_TONES).to match_array(
|
||||
%w[professional casual straightforward confident friendly]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'End-to-End Tests' do
|
||||
context 'when making real API call (stubbed at HTTP level)' do
|
||||
let(:openai_response) do
|
||||
{
|
||||
id: 'chatcmpl-123',
|
||||
object: 'chat.completion',
|
||||
created: 1_677_652_288,
|
||||
model: 'gpt-4o-mini',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: JSON.generate({
|
||||
rewritten_text: 'Good afternoon. I am following up regarding my previous email about the project.',
|
||||
tone_applied: 'professional'
|
||||
})
|
||||
},
|
||||
finish_reason: 'stop'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.to_return(status: 200, body: openai_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'successfully changes tone through entire stack' do
|
||||
response = service.execute
|
||||
|
||||
expect(response[:success]).to be true
|
||||
expect(response[:rewritten_text]).to eq('Good afternoon. I am following up regarding my previous email about the project.')
|
||||
expect(response[:tone]).to eq('professional')
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,222 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Assistant::ConversationSummaryService do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:service) { described_class.new(conversation: conversation) }
|
||||
let(:agent) { instance_double(Agents::Agent) }
|
||||
let(:runner) { instance_double(Agents::Runner) }
|
||||
let(:result) do
|
||||
instance_double(Agents::RunResult,
|
||||
output: {
|
||||
'customer_intent' => 'Customer wants to reset their password',
|
||||
'conversation_summary' => 'The customer contacted support to reset their password. ' \
|
||||
'Agent provided instructions and confirmed the reset was successful.',
|
||||
'action_items' => ['Send password reset email', 'Verify account security'],
|
||||
'follow_up_items' => ['Check if customer needs additional security settings']
|
||||
},
|
||||
error: nil)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'I need to reset my password')
|
||||
create(:message, conversation: conversation, message_type: :outgoing, content: 'I can help you with that')
|
||||
allow(Agents::Agent).to receive(:new).and_return(agent)
|
||||
allow(Agents::Runner).to receive(:with_agents).and_return(runner)
|
||||
allow(runner).to receive(:run).with(anything, context: anything).and_return(result)
|
||||
allow(Captain::PromptRenderer).to receive(:render).and_return('summary prompt')
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
it 'initializes with conversation' do
|
||||
expect(service.instance_variable_get(:@conversation)).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'formats conversation messages as text' do
|
||||
text = service.instance_variable_get(:@text)
|
||||
expect(text).to include('I need to reset my password')
|
||||
expect(text).to include('I can help you with that')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when successful' do
|
||||
it 'renders the summary prompt' do
|
||||
expect(Captain::PromptRenderer).to receive(:render).with('summary', {})
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'builds agent with correct parameters' do
|
||||
expect(Agents::Agent).to receive(:new).with(
|
||||
name: 'ConversationSummarizer',
|
||||
instructions: 'summary prompt',
|
||||
model: 'gpt-4o-mini',
|
||||
response_schema: service.send(:response_schema)
|
||||
)
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'returns success response with structured data' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be true
|
||||
expect(response[:structured_data][:customer_intent]).to eq('Customer wants to reset their password')
|
||||
expect(response[:structured_data][:conversation_summary]).to include('contacted support')
|
||||
expect(response[:structured_data][:action_items]).to include('Send password reset email')
|
||||
expect(response[:structured_data][:follow_up_items]).to include('Check if customer needs additional security settings')
|
||||
end
|
||||
|
||||
it 'includes markdown formatted summary' do
|
||||
response = service.execute
|
||||
expect(response[:summary]).to include('**Customer Intent**')
|
||||
expect(response[:summary]).to include('**Conversation Summary**')
|
||||
expect(response[:summary]).to include('**Action Items**')
|
||||
expect(response[:summary]).to include('**Follow-up Items**')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent returns an error' do
|
||||
let(:result) { instance_double(Agents::RunResult, output: { error: 'Model error' }, error: nil) }
|
||||
|
||||
it 'returns error response' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be false
|
||||
expect(response[:error]).to eq('Model error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when exception is raised' do
|
||||
before do
|
||||
allow(runner).to receive(:run).with(anything, context: anything).and_raise(StandardError.new('API timeout'))
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with(/ConversationSummaryService error: API timeout/)
|
||||
expect(Rails.logger).to receive(:error).with(anything)
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'returns error response' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be false
|
||||
expect(response[:error]).to eq('API timeout')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_name' do
|
||||
it 'returns ConversationSummarizer' do
|
||||
expect(service.send(:agent_name)).to eq('ConversationSummarizer')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_instructions' do
|
||||
it 'renders the summary template' do
|
||||
expect(Captain::PromptRenderer).to receive(:render).with('summary', {})
|
||||
service.send(:build_instructions)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#response_schema' do
|
||||
let(:schema) { service.send(:response_schema) }
|
||||
|
||||
it 'defines object type' do
|
||||
expect(schema[:type]).to eq('object')
|
||||
end
|
||||
|
||||
it 'includes customer_intent property' do
|
||||
expect(schema[:properties][:customer_intent]).to include(
|
||||
type: 'string',
|
||||
description: 'Brief description of what the customer wants (around 50 words)'
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes conversation_summary property' do
|
||||
expect(schema[:properties][:conversation_summary]).to include(
|
||||
type: 'string',
|
||||
description: 'Summary of the conversation in approximately 200 words'
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes action_items property' do
|
||||
expect(schema[:properties][:action_items][:type]).to eq('array')
|
||||
expect(schema[:properties][:action_items][:items][:type]).to eq('string')
|
||||
end
|
||||
|
||||
it 'includes follow_up_items property' do
|
||||
expect(schema[:properties][:follow_up_items][:type]).to eq('array')
|
||||
expect(schema[:properties][:follow_up_items][:items][:type]).to eq('string')
|
||||
end
|
||||
|
||||
it 'marks required fields' do
|
||||
expect(schema[:required]).to match_array(%w[customer_intent conversation_summary])
|
||||
end
|
||||
|
||||
it 'disallows additional properties' do
|
||||
expect(schema[:additionalProperties]).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_success_response' do
|
||||
let(:output) do
|
||||
{
|
||||
'customer_intent' => 'Reset password',
|
||||
'conversation_summary' => 'Password reset conversation',
|
||||
'action_items' => ['Item 1', 'Item 2'],
|
||||
'follow_up_items' => ['Follow-up 1']
|
||||
}
|
||||
end
|
||||
|
||||
it 'extracts customer_intent from output' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:structured_data][:customer_intent]).to eq('Reset password')
|
||||
end
|
||||
|
||||
it 'extracts conversation_summary from output' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:structured_data][:conversation_summary]).to eq('Password reset conversation')
|
||||
end
|
||||
|
||||
it 'extracts action_items from output' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:structured_data][:action_items]).to eq(['Item 1', 'Item 2'])
|
||||
end
|
||||
|
||||
it 'extracts follow_up_items from output' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:structured_data][:follow_up_items]).to eq(['Follow-up 1'])
|
||||
end
|
||||
|
||||
it 'builds markdown summary' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:summary]).to include('**Customer Intent**')
|
||||
expect(response[:summary]).to include('Reset password')
|
||||
end
|
||||
|
||||
it 'marks response as successful' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:success]).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#format_conversation_messages' do
|
||||
it 'includes incoming and outgoing messages' do
|
||||
text = service.send(:format_conversation_messages)
|
||||
expect(text).to include('Customer')
|
||||
expect(text).to include('Agent')
|
||||
end
|
||||
|
||||
it 'excludes private messages' do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Private note', private: true)
|
||||
text = service.send(:format_conversation_messages)
|
||||
expect(text).not_to include('Private note')
|
||||
end
|
||||
|
||||
it 'respects token limit' do
|
||||
long_content = 'a' * 20_000
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: long_content)
|
||||
text = service.send(:format_conversation_messages)
|
||||
expect(text.length).to be <= described_class::TOKEN_LIMIT
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,249 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Assistant::FixGrammarService do
|
||||
let(:text) { 'their going too the store tommorow and there bringing they\'re friends' }
|
||||
let(:service) { described_class.new(text: text) }
|
||||
let(:agent) { instance_double(Agents::Agent) }
|
||||
let(:runner) { instance_double(Agents::Runner) }
|
||||
let(:result) do
|
||||
instance_double(Agents::RunResult,
|
||||
output: {
|
||||
'corrected_text' => 'They\'re going to the store tomorrow and they\'re bringing their friends.',
|
||||
'corrections_made' => [
|
||||
'Changed "their" to "They\'re"',
|
||||
'Changed "too" to "to"',
|
||||
'Fixed spelling: "tommorow" to "tomorrow"',
|
||||
'Changed "there" to "they\'re"',
|
||||
'Changed "they\'re" to "their"'
|
||||
]
|
||||
},
|
||||
error: nil)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
|
||||
allow(Agents::Agent).to receive(:new).and_return(agent)
|
||||
allow(Agents::Runner).to receive(:with_agents).and_return(runner)
|
||||
allow(runner).to receive(:run).with(anything, context: anything).and_return(result)
|
||||
allow(Captain::PromptRenderer).to receive(:render).and_return('grammar prompt')
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
it 'initializes with text only' do
|
||||
expect(service.instance_variable_get(:@text)).to eq(text)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when successful' do
|
||||
it 'renders the grammar prompt' do
|
||||
expect(Captain::PromptRenderer).to receive(:render).with('rewrite/grammar', {})
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'builds agent with correct parameters' do
|
||||
expect(Agents::Agent).to receive(:new).with(
|
||||
name: 'GrammarFixer',
|
||||
instructions: 'grammar prompt',
|
||||
model: 'gpt-4o-mini',
|
||||
response_schema: service.send(:response_schema)
|
||||
)
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'returns success response with corrected text' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be true
|
||||
expect(response[:corrected_text]).to eq('They\'re going to the store tomorrow and they\'re bringing their friends.')
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
|
||||
it 'includes corrections made' do
|
||||
response = service.execute
|
||||
expect(response[:corrections_made]).to be_an(Array)
|
||||
expect(response[:corrections_made]).to include('Changed "their" to "They\'re"')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when output does not include corrections_made' do
|
||||
let(:result) do
|
||||
instance_double(Agents::RunResult,
|
||||
output: {
|
||||
'corrected_text' => 'Corrected text without corrections list'
|
||||
},
|
||||
error: nil)
|
||||
end
|
||||
|
||||
it 'returns empty array for corrections_made' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be true
|
||||
expect(response[:corrections_made]).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent returns an error' do
|
||||
let(:result) { instance_double(Agents::RunResult, output: { error: 'Model error' }, error: nil) }
|
||||
|
||||
it 'returns error response' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be false
|
||||
expect(response[:error]).to eq('Model error')
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when exception is raised' do
|
||||
before do
|
||||
allow(runner).to receive(:run).with(anything, context: anything).and_raise(StandardError.new('API timeout'))
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with(/FixGrammarService error: API timeout/)
|
||||
expect(Rails.logger).to receive(:error).with(anything)
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'returns error response' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be false
|
||||
expect(response[:error]).to eq('API timeout')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_name' do
|
||||
it 'returns GrammarFixer' do
|
||||
expect(service.send(:agent_name)).to eq('GrammarFixer')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_instructions' do
|
||||
it 'renders the grammar template with empty context' do
|
||||
expect(Captain::PromptRenderer).to receive(:render).with('rewrite/grammar', {})
|
||||
service.send(:build_instructions)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#response_schema' do
|
||||
let(:schema) { service.send(:response_schema) }
|
||||
|
||||
it 'defines object type' do
|
||||
expect(schema[:type]).to eq('object')
|
||||
end
|
||||
|
||||
it 'includes corrected_text property' do
|
||||
expect(schema[:properties][:corrected_text]).to include(
|
||||
type: 'string',
|
||||
description: 'The text with corrected grammar, spelling, and punctuation'
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes corrections_made property' do
|
||||
expect(schema[:properties][:corrections_made]).to include(
|
||||
type: 'array',
|
||||
description: 'List of corrections that were made'
|
||||
)
|
||||
end
|
||||
|
||||
it 'marks corrected_text as required' do
|
||||
expect(schema[:required]).to include('corrected_text')
|
||||
end
|
||||
|
||||
it 'disallows additional properties' do
|
||||
expect(schema[:additionalProperties]).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_success_response' do
|
||||
let(:output) do
|
||||
{
|
||||
'corrected_text' => 'Corrected content',
|
||||
'corrections_made' => ['Fix 1', 'Fix 2']
|
||||
}
|
||||
end
|
||||
|
||||
it 'extracts corrected_text from output' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:corrected_text]).to eq('Corrected content')
|
||||
end
|
||||
|
||||
it 'includes corrections made' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:corrections_made]).to eq(['Fix 1', 'Fix 2'])
|
||||
end
|
||||
|
||||
it 'includes original text' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
|
||||
it 'marks response as successful' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:success]).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_corrections' do
|
||||
it 'returns array when output has corrections_made as symbol key' do
|
||||
output = { corrections_made: ['Fix 1'] }
|
||||
expect(service.send(:extract_corrections, output)).to eq(['Fix 1'])
|
||||
end
|
||||
|
||||
it 'returns array when output has corrections_made as string key' do
|
||||
output = { 'corrections_made' => ['Fix 1'] }
|
||||
expect(service.send(:extract_corrections, output)).to eq(['Fix 1'])
|
||||
end
|
||||
|
||||
it 'returns empty array when output is not a hash' do
|
||||
expect(service.send(:extract_corrections, 'string')).to eq([])
|
||||
end
|
||||
|
||||
it 'returns empty array when corrections_made is missing' do
|
||||
expect(service.send(:extract_corrections, {})).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'End-to-End Tests' do
|
||||
context 'when making real API call (stubbed at HTTP level)' do
|
||||
let(:openai_response) do
|
||||
{
|
||||
id: 'chatcmpl-456',
|
||||
object: 'chat.completion',
|
||||
created: 1_677_652_300,
|
||||
model: 'gpt-4o-mini',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: JSON.generate({
|
||||
corrected_text: 'They\'re going to the store tomorrow and they\'re bringing their friends.',
|
||||
corrections_made: [
|
||||
'Changed "their" to "They\'re"',
|
||||
'Changed "too" to "to"',
|
||||
'Fixed spelling: "tommorow" to "tomorrow"'
|
||||
]
|
||||
})
|
||||
},
|
||||
finish_reason: 'stop'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.to_return(status: 200, body: openai_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'successfully fixes grammar through entire stack' do
|
||||
response = service.execute
|
||||
|
||||
expect(response[:success]).to be true
|
||||
expect(response[:corrected_text]).to eq('They\'re going to the store tomorrow and they\'re bringing their friends.')
|
||||
expect(response[:corrections_made]).to include('Changed "their" to "They\'re"')
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user