Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef5d2704eb | ||
|
|
c59dac2fcf | ||
|
|
6f26f0e34b | ||
|
|
51e154790c | ||
|
|
7a2578e814 | ||
|
|
c0f47f1319 | ||
|
|
565a26be21 | ||
|
|
428b07157c | ||
|
|
63837ebef3 | ||
|
|
174630bec1 | ||
|
|
a363f22d82 | ||
|
|
a450d6437d | ||
|
|
a33ee3a1d0 | ||
|
|
1a73682c9a | ||
|
|
5f92426e85 | ||
|
|
68feaf99f6 | ||
|
|
7ef91bee53 | ||
|
|
0d2c0a5656 | ||
|
|
0e54bd17e5 | ||
|
|
6830c3273f | ||
|
|
2c728341e0 | ||
|
|
2b8d1a67b3 | ||
|
|
f4e0481b00 | ||
|
|
67dbcca638 | ||
|
|
1957cd15dc | ||
|
|
88d3876c2b | ||
|
|
29eecfafcb | ||
|
|
dd2f3d80cc | ||
|
|
3728d71176 | ||
|
|
1e57cff70a | ||
|
|
a921a38db9 | ||
|
|
7bb90d84cd | ||
|
|
21ea2ae08d | ||
|
|
35f14c7e02 | ||
|
|
40b77ca001 | ||
|
|
e2cd372d2d | ||
|
|
a1fdba2783 | ||
|
|
d55c5660a3 | ||
|
|
7a8903051d | ||
|
|
1e6291616d | ||
|
|
7a73a68753 | ||
|
|
55d68d930d | ||
|
|
e328492463 | ||
|
|
82f28aa7fb | ||
|
|
5c0cbcbaaa | ||
|
|
5d5fa0c21a | ||
|
|
e12e686244 | ||
|
|
cdccbb39aa | ||
|
|
30ee4a61fc |
+1
-1
@@ -1 +1 @@
|
||||
4.16.1
|
||||
4.16.0
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
# It initializes with necessary attributes and provides a perform method
|
||||
# to create a user and account user in a transaction.
|
||||
class AgentBuilder
|
||||
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
|
||||
|
||||
class LimitExceededError < StandardError
|
||||
def initialize
|
||||
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
|
||||
end
|
||||
end
|
||||
|
||||
# Initializes an AgentBuilder with necessary attributes.
|
||||
# @param email [String] the email of the user.
|
||||
# @param name [String] the name of the user.
|
||||
@@ -22,23 +14,15 @@ class AgentBuilder
|
||||
# Creates a user and account user in a transaction.
|
||||
# @return [User] the created user.
|
||||
def perform
|
||||
account.with_lock do
|
||||
raise LimitExceededError unless can_add_agent?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
end
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
end
|
||||
@user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_add_agent?
|
||||
account.usage_limits[:agents] > account.account_users.count
|
||||
end
|
||||
|
||||
# Finds a user by email or creates a new one with a temporary password.
|
||||
# @return [User] the found or created user.
|
||||
def find_or_create_user
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent, except: [:create, :index, :bulk_create]
|
||||
before_action :check_authorization
|
||||
before_action :validate_limit, only: [:create]
|
||||
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
|
||||
|
||||
def index
|
||||
@agents = agents
|
||||
@@ -18,8 +20,6 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
)
|
||||
|
||||
@agent = builder.perform
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -36,13 +36,25 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
def bulk_create
|
||||
emails = params[:emails]
|
||||
|
||||
bulk_create_agents(emails)
|
||||
emails.each do |email|
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
begin
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
end
|
||||
|
||||
# This endpoint is used to bulk create agents during onboarding
|
||||
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
|
||||
clear_onboarding_step
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
head :ok
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
@@ -75,33 +87,22 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
||||
end
|
||||
|
||||
def bulk_create_agents(emails)
|
||||
Current.account.with_lock do
|
||||
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
|
||||
def validate_limit_for_bulk_create
|
||||
limit_available = params[:emails].count <= available_agent_count
|
||||
|
||||
emails.each { |email| create_agent_from_email(email) }
|
||||
end
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
|
||||
end
|
||||
|
||||
def create_agent_from_email(email)
|
||||
builder = AgentBuilder.new(
|
||||
email: email,
|
||||
name: email.split('@').first,
|
||||
inviter: current_user,
|
||||
account: Current.account
|
||||
)
|
||||
builder.perform
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||
end
|
||||
|
||||
def clear_onboarding_step
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
def validate_limit
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
|
||||
end
|
||||
|
||||
def available_agent_count
|
||||
Current.account.usage_limits[:agents] - Current.account.account_users.count
|
||||
Current.account.usage_limits[:agents] - agents.count
|
||||
end
|
||||
|
||||
def can_add_agent?
|
||||
available_agent_count.positive?
|
||||
end
|
||||
|
||||
def delete_user_record(agent)
|
||||
|
||||
@@ -3,6 +3,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
|
||||
before_action :check_authorization
|
||||
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
|
||||
before_action :ensure_execution_delay_allowed, only: [:create, :update]
|
||||
|
||||
def index
|
||||
@automation_rules = Current.account.automation_rules
|
||||
@@ -48,6 +49,9 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
def clone
|
||||
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
|
||||
new_rule = automation_rule.dup
|
||||
# dup copies execution_delay; drop it when the feature is off so clone can't create new
|
||||
# delayed rules that create/update would reject.
|
||||
new_rule.execution_delay = nil unless delayed_automations_enabled?
|
||||
new_rule.save!
|
||||
@automation_rule = new_rule
|
||||
end
|
||||
@@ -55,13 +59,27 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
private
|
||||
|
||||
def automation_rules_permit
|
||||
permitted_attributes = [:name, :description, :event_name, :active]
|
||||
permitted_attributes << :execution_delay if delayed_automations_enabled?
|
||||
|
||||
params.permit(
|
||||
:name, :description, :event_name, :active,
|
||||
*permitted_attributes,
|
||||
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
||||
actions: [:action_name, { action_params: [] }]
|
||||
)
|
||||
end
|
||||
|
||||
def ensure_execution_delay_allowed
|
||||
return if delayed_automations_enabled?
|
||||
return if params[:execution_delay].blank?
|
||||
|
||||
render json: { error: 'Delayed automations are not enabled for this account.' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def delayed_automations_enabled?
|
||||
Current.account.feature_enabled?('delayed_automations')
|
||||
end
|
||||
|
||||
def fetch_automation_rule
|
||||
@automation_rule = Current.account.automation_rules.find_by(id: params[:id])
|
||||
end
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
|
||||
private
|
||||
|
||||
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
|
||||
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook, except: [:create]
|
||||
before_action :check_authorization
|
||||
|
||||
@@ -35,6 +35,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
|
||||
@hook = Current.account.hooks.find(params[:id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
||||
end
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
revoke_linear_token
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
|
||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
|
||||
include Shopify::IntegrationHelper
|
||||
before_action :setup_shopify_context, only: [:orders]
|
||||
before_action :fetch_hook, except: [:auth]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
before_action :validate_contact, only: [:orders]
|
||||
|
||||
def auth
|
||||
|
||||
@@ -26,20 +26,13 @@ class CaptainAssistant extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getMetrics({ assistantId, range, signal }) {
|
||||
getStats({ assistantId, range, signal }) {
|
||||
const requestConfig = {
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
};
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
|
||||
}
|
||||
|
||||
getFaqStats({ assistantId, signal }) {
|
||||
const requestConfig = {};
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
|
||||
return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
|
||||
}
|
||||
|
||||
getSummary({ assistantId, range, stats }) {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainFaqSuggestions extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/faq_suggestions', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, search, assistantId, status = 'open', signal } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
search,
|
||||
assistant_id: assistantId,
|
||||
status,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
update(id, data) {
|
||||
return axios.patch(`${this.url}/${id}`, { faq_suggestion: data });
|
||||
}
|
||||
|
||||
approve(id, data) {
|
||||
return axios.post(`${this.url}/${id}/approve`, {
|
||||
faq_suggestion: data,
|
||||
});
|
||||
}
|
||||
|
||||
dismiss(id) {
|
||||
return axios.post(`${this.url}/${id}/dismiss`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainFaqSuggestions();
|
||||
@@ -6,15 +6,15 @@ class CaptainResponses extends ApiClient {
|
||||
super('captain/assistant_responses', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, search, assistantId, documentId, signal } = {}) {
|
||||
get({ page = 1, search, assistantId, documentId, status } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
search,
|
||||
assistant_id: assistantId,
|
||||
document_id: documentId,
|
||||
status,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
suggestion: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['approve', 'dismiss', 'review']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const sourceLabel = computed(() =>
|
||||
t('CAPTAIN.FAQ_SUGGESTIONS.SOURCE_COUNT', {
|
||||
count: props.suggestion.source_count,
|
||||
})
|
||||
);
|
||||
|
||||
const updatedAt = computed(() =>
|
||||
dynamicTime(props.suggestion.updated_at || props.suggestion.created_at)
|
||||
);
|
||||
|
||||
const language = computed(() =>
|
||||
props.suggestion.language?.replace('_', '-').toUpperCase()
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-n-brand/10 px-2.5 py-1 text-xs font-medium text-n-blue-11"
|
||||
>
|
||||
<Icon icon="i-lucide-messages-square" class="size-3.5" />
|
||||
{{ sourceLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-if="language"
|
||||
class="rounded-full bg-n-alpha-2 px-2.5 py-1 text-xs font-medium text-n-slate-11"
|
||||
>
|
||||
{{ language }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ suggestion.question }}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.REVIEW')"
|
||||
icon="i-lucide-list-collapse"
|
||||
size="sm"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
class="shrink-0"
|
||||
@click="emit('review', suggestion)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="line-clamp-2 text-sm leading-5 text-n-slate-11">
|
||||
{{ suggestion.answer }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-3 border-t border-n-weak pt-2.5 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 text-xs text-n-slate-10"
|
||||
>
|
||||
<span class="inline-flex min-w-0 items-center gap-1.5">
|
||||
<Icon icon="i-woot-captain" class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{{ suggestion.assistant?.name }}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<Icon icon="i-lucide-clock-3" class="size-3.5" />
|
||||
{{ updatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.DISMISS')"
|
||||
icon="i-lucide-circle-x"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
:disabled="isLoading"
|
||||
@click="emit('dismiss', suggestion)"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.APPROVE')"
|
||||
icon="i-lucide-circle-check-big"
|
||||
size="sm"
|
||||
:is-loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
@click="emit('approve', suggestion)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+39
-5
@@ -7,7 +7,24 @@ import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCa
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'approved',
|
||||
validator: value => ['approved', 'pending'].includes(value),
|
||||
},
|
||||
hasActiveFilters: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'clearFilters']);
|
||||
|
||||
const isApproved = computed(() => props.variant === 'approved');
|
||||
const isPending = computed(() => props.variant === 'pending');
|
||||
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
@@ -15,10 +32,15 @@ const { replaceInstallationName } = useBranding();
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
|
||||
const onClearFilters = () => {
|
||||
emit('clearFilters');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureSpotlight
|
||||
v-if="isApproved"
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
|
||||
@@ -28,12 +50,16 @@ const onClick = () => {
|
||||
class="mb-8"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
|
||||
:title="
|
||||
isPending
|
||||
? $t('CAPTAIN.RESPONSES.EMPTY_STATE.NO_PENDING_TITLE')
|
||||
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
|
||||
"
|
||||
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
|
||||
:action-perms="['administrator']"
|
||||
show-backdrop
|
||||
:show-backdrop="isApproved"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<template v-if="isApproved" #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
<ResponseCard
|
||||
v-for="(response, index) in responsesList.slice(0, 5)"
|
||||
@@ -51,10 +77,18 @@ const onClick = () => {
|
||||
<template #actions>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<Button
|
||||
v-if="isApproved"
|
||||
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
icon="i-lucide-plus"
|
||||
@click="onClick"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="isPending && hasActiveFilters"
|
||||
:label="$t('CAPTAIN.RESPONSES.EMPTY_STATE.CLEAR_SEARCH')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="onClearFilters"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
|
||||
+9
-14
@@ -6,12 +6,7 @@ import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
const props = defineProps({
|
||||
knowledge: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
approved: 0,
|
||||
suggestions: 0,
|
||||
documents: 0,
|
||||
coverage: 0,
|
||||
}),
|
||||
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,16 +31,16 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Thin coverage paired with a large review backlog: approving FAQ suggestions
|
||||
// Thin coverage paired with a large review backlog: approving the pending FAQs
|
||||
// is the quickest lever to lift auto-resolution, so nudge the team to act.
|
||||
const COVERAGE_THRESHOLD = 85;
|
||||
const SUGGESTION_THRESHOLD = 100;
|
||||
const PENDING_THRESHOLD = 100;
|
||||
|
||||
const showBanner = computed(
|
||||
() =>
|
||||
!dismissed.value &&
|
||||
(props.knowledge?.coverage ?? 0) < COVERAGE_THRESHOLD &&
|
||||
(props.knowledge?.suggestions ?? 0) > SUGGESTION_THRESHOLD
|
||||
(props.knowledge?.pending ?? 0) > PENDING_THRESHOLD
|
||||
);
|
||||
|
||||
const dismiss = () => {
|
||||
@@ -53,9 +48,9 @@ const dismiss = () => {
|
||||
dismissed.value = true;
|
||||
};
|
||||
|
||||
const goToSuggestions = () => {
|
||||
const goToPending = () => {
|
||||
router.push({
|
||||
name: 'captain_assistants_faq_suggestions',
|
||||
name: 'captain_assistants_responses_pending',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: route.params.assistantId,
|
||||
@@ -66,15 +61,15 @@ const goToSuggestions = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showBanner"
|
||||
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
|
||||
:class="{ hidden: !showBanner }"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="shrink-0 i-lucide-triangle-alert size-4" />
|
||||
<span class="truncate">
|
||||
{{
|
||||
$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.TEXT', {
|
||||
count: knowledge.suggestions,
|
||||
count: knowledge.pending,
|
||||
coverage: knowledge.coverage,
|
||||
})
|
||||
}}
|
||||
@@ -84,7 +79,7 @@ const goToSuggestions = () => {
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
|
||||
@click="goToSuggestions"
|
||||
@click="goToPending"
|
||||
>
|
||||
{{ $t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.ACTION') }}
|
||||
</button>
|
||||
|
||||
+5
-10
@@ -6,12 +6,7 @@ import { useI18n } from 'vue-i18n';
|
||||
const props = defineProps({
|
||||
knowledge: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
approved: 0,
|
||||
suggestions: 0,
|
||||
documents: 0,
|
||||
coverage: 0,
|
||||
}),
|
||||
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -36,10 +31,10 @@ const stats = computed(() => [
|
||||
to: linkTo('captain_assistants_responses_index'),
|
||||
},
|
||||
{
|
||||
key: 'suggestions',
|
||||
value: props.knowledge.suggestions,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.SUGGESTIONS'),
|
||||
to: linkTo('captain_assistants_faq_suggestions'),
|
||||
key: 'pending',
|
||||
value: props.knowledge.pending,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.PENDING'),
|
||||
to: linkTo('captain_assistants_responses_pending'),
|
||||
},
|
||||
{
|
||||
key: 'documents',
|
||||
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import FaqSuggestionReviewDialog from './FaqSuggestionReviewDialog.vue';
|
||||
|
||||
const { dispatch, push, uiFlags } = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
push: vi.fn(),
|
||||
uiFlags: {
|
||||
value: {
|
||||
fetchingItem: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useStore: () => ({ dispatch }),
|
||||
useMapGetter: () => uiFlags,
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push }),
|
||||
}));
|
||||
|
||||
const DialogStub = {
|
||||
methods: { close: vi.fn() },
|
||||
template: `
|
||||
<div>
|
||||
<slot name="description" />
|
||||
<slot />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
describe('FaqSuggestionReviewDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps a visible error when source conversations fail to load', async () => {
|
||||
dispatch.mockRejectedValueOnce(new Error('Request failed'));
|
||||
|
||||
const wrapper = shallowMount(FaqSuggestionReviewDialog, {
|
||||
props: {
|
||||
suggestion: {
|
||||
id: 1,
|
||||
question: 'How do I enable the feature?',
|
||||
answer: 'Turn it on in settings.',
|
||||
source_count: 2,
|
||||
assistant: { name: 'Support assistant' },
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
global: {
|
||||
mocks: { $t: key => key },
|
||||
stubs: { Dialog: DialogStub },
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain(
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD_DETAILS'
|
||||
);
|
||||
expect(wrapper.text()).not.toContain(
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.DETAILS.NO_SOURCES'
|
||||
);
|
||||
});
|
||||
|
||||
it('opens source conversations using their display ID', async () => {
|
||||
dispatch.mockResolvedValueOnce({
|
||||
observations: [
|
||||
{
|
||||
id: 1,
|
||||
generated_question: 'How do I enable the feature?',
|
||||
created_at: 1,
|
||||
conversation: { id: 99, display_id: 42 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const wrapper = shallowMount(FaqSuggestionReviewDialog, {
|
||||
props: {
|
||||
suggestion: {
|
||||
id: 1,
|
||||
question: 'How do I enable the feature?',
|
||||
answer: 'Turn it on in settings.',
|
||||
source_count: 1,
|
||||
assistant: { name: 'Support assistant' },
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
global: {
|
||||
mocks: { $t: key => key },
|
||||
stubs: { Dialog: DialogStub },
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await wrapper.get('section button').trigger('click');
|
||||
|
||||
expect(push).toHaveBeenCalledWith({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: 42 },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the review actions to users who can open the page', async () => {
|
||||
dispatch.mockResolvedValueOnce({ observations: [] });
|
||||
|
||||
const wrapper = shallowMount(FaqSuggestionReviewDialog, {
|
||||
props: {
|
||||
suggestion: {
|
||||
id: 1,
|
||||
question: 'How do I enable the feature?',
|
||||
answer: 'Turn it on in settings.',
|
||||
source_count: 1,
|
||||
assistant: { name: 'Support assistant' },
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
global: {
|
||||
mocks: { $t: key => key },
|
||||
stubs: { Dialog: DialogStub },
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper.findAllComponents(Button).map(button => button.props('label'))
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.DISMISS',
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.SAVE',
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.APPROVE_FAQ',
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
-321
@@ -1,321 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
|
||||
const props = defineProps({
|
||||
suggestion: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'resolved']);
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const dialogRef = ref(null);
|
||||
const details = ref(null);
|
||||
const detailsError = ref(false);
|
||||
|
||||
const uiFlags = useMapGetter('captainFaqSuggestions/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingItem);
|
||||
const isSaving = computed(() => uiFlags.value.updatingItem);
|
||||
const isDismissing = computed(() => uiFlags.value.deletingItem);
|
||||
|
||||
const state = reactive({
|
||||
question: props.suggestion.question,
|
||||
answer: props.suggestion.answer,
|
||||
});
|
||||
|
||||
const observations = computed(() => details.value?.observations || []);
|
||||
const isInvalid = computed(
|
||||
() => !state.question.trim() || !state.answer.trim()
|
||||
);
|
||||
|
||||
const loadDetails = async () => {
|
||||
detailsError.value = false;
|
||||
|
||||
try {
|
||||
details.value = await store.dispatch(
|
||||
'captainFaqSuggestions/show',
|
||||
props.suggestion.id
|
||||
);
|
||||
} catch (error) {
|
||||
detailsError.value = true;
|
||||
useAlert(
|
||||
error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD_DETAILS')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => dialogRef.value.close();
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const updatedSuggestion = await store.dispatch(
|
||||
'captainFaqSuggestions/update',
|
||||
{
|
||||
id: props.suggestion.id,
|
||||
question: state.question.trim(),
|
||||
answer: state.answer.trim(),
|
||||
}
|
||||
);
|
||||
details.value = { ...details.value, ...updatedSuggestion };
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.SAVED'));
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.SAVE'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
await store.dispatch('captainFaqSuggestions/approve', {
|
||||
id: props.suggestion.id,
|
||||
question: state.question.trim(),
|
||||
answer: state.answer.trim(),
|
||||
});
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.APPROVED'));
|
||||
emit('resolved', { id: props.suggestion.id, action: 'approved' });
|
||||
close();
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.APPROVE'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = async () => {
|
||||
try {
|
||||
await store.dispatch('captainFaqSuggestions/dismiss', props.suggestion.id);
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.DISMISSED'));
|
||||
emit('resolved', { id: props.suggestion.id, action: 'dismissed' });
|
||||
close();
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.DISMISS'));
|
||||
}
|
||||
};
|
||||
|
||||
const openConversation = conversationId => {
|
||||
close();
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: conversationId },
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(loadDetails);
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="3xl"
|
||||
position="top"
|
||||
overflow-y-auto
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.TITLE') }}
|
||||
</h3>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{
|
||||
$t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.DESCRIPTION', {
|
||||
count: suggestion.source_count,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
icon="i-lucide-x"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="-me-1.5 -mt-1.5 shrink-0"
|
||||
:aria-label="$t('DIALOG.BUTTONS.CANCEL')"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid min-h-0 grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<Input
|
||||
v-model="state.question"
|
||||
:label="$t('CAPTAIN.RESPONSES.FORM.QUESTION.LABEL')"
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.FORM.QUESTION.PLACEHOLDER')"
|
||||
/>
|
||||
<TextArea
|
||||
v-model="state.answer"
|
||||
:label="$t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.FORM.ANSWER.PLACEHOLDER')"
|
||||
auto-height
|
||||
resize
|
||||
min-height="10rem"
|
||||
max-height="20rem"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-n-slate-10">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-n-alpha-2 px-2.5 py-1"
|
||||
>
|
||||
<Icon icon="i-lucide-languages" class="size-3.5" />
|
||||
{{ suggestion.language?.replace('_', '-').toUpperCase() }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-n-alpha-2 px-2.5 py-1"
|
||||
>
|
||||
<Icon icon="i-woot-captain" class="size-3.5" />
|
||||
{{ suggestion.assistant?.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="min-h-0 rounded-xl border border-n-weak bg-n-alpha-2 p-3">
|
||||
<div class="mb-3 flex items-center justify-between gap-2 px-1">
|
||||
<h4 class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.SOURCES') }}
|
||||
</h4>
|
||||
<span class="text-xs tabular-nums text-n-slate-10">
|
||||
{{
|
||||
$t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.SOURCE_PROGRESS', {
|
||||
visible: observations.length,
|
||||
total: suggestion.source_count,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex max-h-96 flex-col gap-2 overflow-hidden"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div
|
||||
v-for="n in Math.min(suggestion.source_count, 6)"
|
||||
:key="n"
|
||||
class="flex w-full animate-pulse flex-col gap-1.5 rounded-lg outline outline-1 -outline-offset-1 outline-n-weak bg-n-solid-2 p-3"
|
||||
>
|
||||
<span class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-medium">
|
||||
<span
|
||||
class="inline-block h-2 w-24 rounded-sm bg-n-alpha-2 align-middle"
|
||||
/>
|
||||
</span>
|
||||
<span class="size-3.5 shrink-0 rounded-sm bg-n-alpha-2" />
|
||||
</span>
|
||||
<span class="flex min-h-[2.5rem] flex-col text-xs leading-5">
|
||||
<span class="h-2 w-full rounded-sm bg-n-alpha-2" />
|
||||
<span class="mt-2 h-2 w-4/5 rounded-sm bg-n-alpha-2" />
|
||||
</span>
|
||||
<span class="text-xs">
|
||||
<span
|
||||
class="inline-block h-2 w-16 rounded-sm bg-n-alpha-2 align-middle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="detailsError"
|
||||
role="alert"
|
||||
class="flex min-h-[10rem] flex-col items-center justify-center gap-3 px-4 text-center"
|
||||
>
|
||||
<Icon icon="i-lucide-circle-alert" class="size-5 text-n-ruby-10" />
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD_DETAILS') }}
|
||||
</p>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.RETRY')"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@click="loadDetails"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!observations.length"
|
||||
class="flex min-h-[10rem] items-center justify-center px-4 text-center text-sm text-n-slate-10"
|
||||
>
|
||||
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.NO_SOURCES') }}
|
||||
</div>
|
||||
<div v-else class="flex max-h-96 flex-col gap-2 overflow-y-auto">
|
||||
<button
|
||||
v-for="observation in observations"
|
||||
:key="observation.id"
|
||||
type="button"
|
||||
class="flex w-full flex-col gap-1.5 rounded-lg border border-n-weak bg-n-solid-2 p-3 text-start transition-colors hover:border-n-brand"
|
||||
@click="openConversation(observation.conversation.display_id)"
|
||||
>
|
||||
<span class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-medium text-n-blue-11">
|
||||
{{
|
||||
$t('CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION', {
|
||||
id: observation.conversation.display_id,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<Icon
|
||||
icon="i-lucide-arrow-up-right"
|
||||
class="size-3.5 text-n-slate-10"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="line-clamp-2 min-h-[2.5rem] text-xs leading-5 text-n-slate-11"
|
||||
>
|
||||
{{ observation.generated_question }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ dynamicTime(observation.created_at) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div
|
||||
class="flex w-full flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.DISMISS')"
|
||||
icon="i-lucide-circle-x"
|
||||
variant="ghost"
|
||||
color="ruby"
|
||||
:is-loading="isDismissing"
|
||||
:disabled="isSaving || isDismissing"
|
||||
@click="handleDismiss"
|
||||
/>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.SAVE')"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:is-loading="isSaving"
|
||||
:disabled="isInvalid || isSaving || isDismissing"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.APPROVE_FAQ')"
|
||||
icon="i-lucide-circle-check-big"
|
||||
:is-loading="isSaving"
|
||||
:disabled="isInvalid || isSaving || isDismissing"
|
||||
@click="handleApprove"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
+10
@@ -24,6 +24,16 @@ const isAssistantActive = assistant => {
|
||||
|
||||
const fetchDataForRoute = async (routeName, assistantId) => {
|
||||
const dataFetchMap = {
|
||||
captain_assistants_responses_index: async () => {
|
||||
await store.dispatch('captainResponses/get', { assistantId });
|
||||
await store.dispatch('captainResponses/fetchPendingCount', assistantId);
|
||||
},
|
||||
captain_assistants_responses_pending: async () => {
|
||||
await store.dispatch('captainResponses/get', {
|
||||
assistantId,
|
||||
status: 'pending',
|
||||
});
|
||||
},
|
||||
captain_assistants_documents_index: async () => {
|
||||
await store.dispatch('captainDocuments/get', { assistantId });
|
||||
},
|
||||
|
||||
@@ -511,7 +511,7 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
||||
activeOn: [
|
||||
'captain_assistants_responses_index',
|
||||
'captain_assistants_faq_suggestions',
|
||||
'captain_assistants_responses_pending',
|
||||
],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_responses_index',
|
||||
|
||||
@@ -77,15 +77,6 @@ const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleFocusOut = event => {
|
||||
// Keep the menu open while focus stays inside it (e.g. the label search
|
||||
// input); close it once focus leaves the menu entirely.
|
||||
if (menuRef.value?.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
isLocked.value = false;
|
||||
});
|
||||
@@ -98,7 +89,7 @@ onUnmounted(() => {
|
||||
class="fixed outline-none z-[9999] cursor-pointer"
|
||||
:style="position"
|
||||
tabindex="0"
|
||||
@focusout="handleFocusOut"
|
||||
@blur="handleClose"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
EditorState,
|
||||
Selection,
|
||||
imageResizeView,
|
||||
toggleMark,
|
||||
wrapInList,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
@@ -19,6 +17,8 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
||||
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
|
||||
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
|
||||
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
|
||||
import { toggleMark } from 'prosemirror-commands';
|
||||
import { wrapInList } from 'prosemirror-schema-list';
|
||||
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
@@ -33,7 +33,9 @@ import {
|
||||
// constants
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import wootConstants, {
|
||||
META_RESTRICTION_STATUS_URL,
|
||||
} from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
@@ -93,6 +95,7 @@ export default {
|
||||
currentUserId: 'getCurrentUserID',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
isOpen() {
|
||||
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
@@ -170,6 +173,13 @@ export default {
|
||||
instagramInbox
|
||||
);
|
||||
},
|
||||
isInstagramRestrictionBannerVisible() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
instagramRestrictionStatusUrl() {
|
||||
return META_RESTRICTION_STATUS_URL;
|
||||
},
|
||||
|
||||
replyWindowBannerMessage() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
|
||||
@@ -454,7 +464,15 @@ export default {
|
||||
>
|
||||
<div ref="topBannerRef">
|
||||
<Banner
|
||||
v-if="!currentChat.can_reply"
|
||||
v-if="isInstagramRestrictionBannerVisible"
|
||||
color-scheme="warning"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
|
||||
:href-link="instagramRestrictionStatusUrl"
|
||||
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
|
||||
@@ -7,13 +7,10 @@ import {
|
||||
getSortedAgentsByAvailability,
|
||||
getAgentsByUpdatedPresence,
|
||||
} from 'dashboard/helper/agentHelper.js';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import MenuItem from './menuItem.vue';
|
||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||
import NextInput from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const MENU = {
|
||||
MARK_AS_READ: 'mark-as-read',
|
||||
@@ -34,8 +31,6 @@ export default {
|
||||
MenuItem,
|
||||
MenuItemWithSubmenu,
|
||||
AgentLoadingPlaceholder,
|
||||
NextInput,
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
chatId: {
|
||||
@@ -92,7 +87,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
MENU,
|
||||
labelSearchQuery: '',
|
||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||
readOption: {
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
|
||||
@@ -222,14 +216,6 @@ export default {
|
||||
// Don't show snooze if the conversation is already snoozed/resolved/pending
|
||||
return this.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
},
|
||||
filteredLabels() {
|
||||
const labels = this.labelSearchQuery
|
||||
? picoSearch(this.labels, this.labelSearchQuery, ['title'])
|
||||
: this.labels;
|
||||
// Assigned labels first, keeping each group's existing order.
|
||||
const isAssigned = label => this.conversationLabels.includes(label.title);
|
||||
return [...labels].sort((a, b) => isAssigned(b) - isAssigned(a));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
|
||||
@@ -349,49 +335,21 @@ export default {
|
||||
:option="labelMenuConfig"
|
||||
:sub-menu-available="!!labels.length"
|
||||
>
|
||||
<div class="pb-1 w-[12.5rem]">
|
||||
<NextInput
|
||||
v-model="labelSearchQuery"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
custom-input-class="!ps-8 !text-xs"
|
||||
:placeholder="$t('CONVERSATION.CARD_CONTEXT_MENU.SEARCH_LABELS')"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute z-10 -translate-y-1/2 pointer-events-none size-3.5 text-n-slate-10 top-1/2 start-2"
|
||||
/>
|
||||
</template>
|
||||
</NextInput>
|
||||
</div>
|
||||
<div class="overflow-x-hidden overflow-y-auto max-h-[12.5rem]">
|
||||
<MenuItem
|
||||
v-for="label in filteredLabels"
|
||||
:key="label.id"
|
||||
:option="generateMenuLabelConfig(label, 'label')"
|
||||
:variant="
|
||||
conversationLabels.includes(label.title)
|
||||
? 'label-assigned'
|
||||
: 'label'
|
||||
"
|
||||
@mousedown.prevent
|
||||
@click.stop="
|
||||
conversationLabels.includes(label.title)
|
||||
? $emit('removeLabel', label)
|
||||
: $emit('assignLabel', label)
|
||||
"
|
||||
/>
|
||||
<p
|
||||
v-if="!filteredLabels.length"
|
||||
class="px-2 py-2 m-0 text-xs text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CONVERSATION.CARD_CONTEXT_MENU.NO_LABELS_FOUND') }}
|
||||
</p>
|
||||
</div>
|
||||
<MenuItem
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:option="generateMenuLabelConfig(label, 'label')"
|
||||
:variant="
|
||||
conversationLabels.includes(label.title)
|
||||
? 'label-assigned'
|
||||
: 'label'
|
||||
"
|
||||
@click.stop="
|
||||
conversationLabels.includes(label.title)
|
||||
? $emit('removeLabel', label)
|
||||
: $emit('assignLabel', label)
|
||||
"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
v-if="isAllowed([MENU.AGENT])"
|
||||
|
||||
@@ -15,7 +15,7 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menu group text-n-slate-12 min-h-7 min-w-0" role="button">
|
||||
<div class="menu text-n-slate-12 min-h-7 min-w-0" role="button">
|
||||
<fluent-icon
|
||||
v-if="variant === 'icon' && option.icon"
|
||||
:icon="option.icon"
|
||||
@@ -52,7 +52,7 @@ defineProps({
|
||||
<Icon
|
||||
v-if="variant === 'label-assigned'"
|
||||
icon="i-lucide-check"
|
||||
class="flex-shrink-0 size-3.5 text-n-brand group-hover:text-white"
|
||||
class="flex-shrink-0 size-3.5 mr-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -78,3 +78,5 @@ export default {
|
||||
},
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
export const META_RESTRICTION_STATUS_URL =
|
||||
'https://status.chatwoot.com/incident/948346';
|
||||
|
||||
@@ -14,6 +14,7 @@ export const FEATURE_FLAGS = {
|
||||
CRM: 'crm',
|
||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||
DATA_IMPORT: 'data_import',
|
||||
DELAYED_AUTOMATIONS: 'delayed_automations',
|
||||
API_AND_WEBHOOKS: 'api_and_webhooks',
|
||||
INBOX_MANAGEMENT: 'inbox_management',
|
||||
INTEGRATIONS: 'integrations',
|
||||
|
||||
@@ -208,6 +208,12 @@ export const generateAutomationPayload = payload => {
|
||||
return automation;
|
||||
};
|
||||
|
||||
export const formatDelay = minutes => {
|
||||
if (minutes % 1440 === 0) return `${minutes / 1440}d`;
|
||||
if (minutes % 60 === 0) return `${minutes / 60}h`;
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
export const isCustomAttribute = (attrs, key) => {
|
||||
return attrs.find(attr => attr.key === key);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
InputRule,
|
||||
inputRules,
|
||||
MessageMarkdownSerializer,
|
||||
MessageMarkdownTransformer,
|
||||
messageSchema,
|
||||
@@ -11,6 +9,7 @@ import * as Sentry from '@sentry/vue';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import { InputRule, inputRules } from 'prosemirror-inputrules';
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
|
||||
@@ -28,6 +28,35 @@
|
||||
"PLACEHOLDER": "Please select one",
|
||||
"ERROR": "Event is required"
|
||||
},
|
||||
"EXECUTE": {
|
||||
"LABEL": "Delayed execution",
|
||||
"AFTER_DELAY": "Run after",
|
||||
"UNITS": {
|
||||
"MINUTES": "Minutes",
|
||||
"HOURS": "Hours",
|
||||
"DAYS": "Days"
|
||||
},
|
||||
"ERROR": "Delay must be between 10 minutes and 30 days",
|
||||
"ENDS_IF_LABEL": "Won't run if",
|
||||
"ENDS_IF": {
|
||||
"STATUS": "the conversation's status changes, or its conditions no longer match.",
|
||||
"CUSTOMER_REPLY": "the customer replies, or the conditions no longer match.",
|
||||
"AGENT_REPLY": "an agent replies, or the conditions no longer match.",
|
||||
"GENERIC": "there is a new reply on the conversation, or the conditions no longer match."
|
||||
},
|
||||
"HELP_TEXT": "Only applies to conversations with activity after the rule is created."
|
||||
},
|
||||
"TRIGGER": {
|
||||
"LABEL": "Trigger",
|
||||
"WHEN_LABEL": "When",
|
||||
"STATUS_LABEL": "Status is",
|
||||
"INBOX_LABEL": "Inbox",
|
||||
"OPTIONS": {
|
||||
"CUSTOMER_UNRESPONSIVE": "Customer unresponsive",
|
||||
"AGENT_UNRESPONSIVE": "Teammate unresponsive",
|
||||
"CONVERSATION_STATUS": "Conversation in a status"
|
||||
}
|
||||
},
|
||||
"CONDITIONS": {
|
||||
"LABEL": "Conditions"
|
||||
},
|
||||
@@ -49,7 +78,13 @@
|
||||
"CREATED_ON": "Created on",
|
||||
"ACTIONS": "Actions"
|
||||
},
|
||||
"404": "No automation rules found"
|
||||
"404": "No automation rules found",
|
||||
"SECTIONS": {
|
||||
"INSTANT": "Automations",
|
||||
"DELAYED": "Delayed execution"
|
||||
},
|
||||
"DELAY_BADGE": "Runs after {delay}",
|
||||
"DELAY_DISABLED_BANNER": "Delayed execution is disabled for this account. Delayed rules won't run until it is enabled again."
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Delete Automation Rule",
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
@@ -193,8 +195,6 @@
|
||||
},
|
||||
"ASSIGN_AGENT": "Assign agent",
|
||||
"ASSIGN_LABEL": "Assign label",
|
||||
"SEARCH_LABELS": "Search labels",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
"AGENTS_LOADING": "Loading agents...",
|
||||
"ASSIGN_TEAM": "Assign team",
|
||||
"DELETE": "Delete conversation",
|
||||
|
||||
@@ -58,7 +58,9 @@
|
||||
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
|
||||
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
|
||||
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"STATUS_LINK": "View status update"
|
||||
},
|
||||
"TIKTOK": {
|
||||
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
|
||||
|
||||
@@ -411,8 +411,8 @@
|
||||
"DISMISS": "Dismiss"
|
||||
},
|
||||
"COVERAGE_BANNER": {
|
||||
"TEXT": "{count} FAQ suggestions are ready for review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
|
||||
"ACTION": "Review suggestions",
|
||||
"TEXT": "{count} FAQs are pending review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
|
||||
"ACTION": "Review FAQs",
|
||||
"DISMISS": "Dismiss"
|
||||
},
|
||||
"RANGES": {
|
||||
@@ -457,7 +457,7 @@
|
||||
"TITLE": "Knowledge coverage",
|
||||
"COVERAGE": "{pct}% approved",
|
||||
"APPROVED": "Approved FAQs",
|
||||
"SUGGESTIONS": "FAQ suggestions",
|
||||
"PENDING": "Pending FAQs",
|
||||
"DOCUMENTS": "Documents"
|
||||
},
|
||||
"LINKS": {
|
||||
@@ -1086,6 +1086,7 @@
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"PENDING_FAQS": "Pending FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
@@ -1094,10 +1095,12 @@
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"SEARCH_PLACEHOLDER": "Search FAQs...",
|
||||
"ERRORS": {
|
||||
"LOAD": "There was an error loading FAQs. Please try again."
|
||||
},
|
||||
"BULK_APPROVE_BUTTON": "Approve",
|
||||
"BULK_DELETE_BUTTON": "Delete",
|
||||
"BULK_APPROVE": {
|
||||
"SUCCESS_MESSAGE": "FAQs approved successfully",
|
||||
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
|
||||
},
|
||||
"BULK_DELETE": {
|
||||
"TITLE": "Delete FAQs?",
|
||||
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
|
||||
@@ -1112,9 +1115,20 @@
|
||||
"SUCCESS_MESSAGE": "FAQ deleted successfully",
|
||||
"ERROR_MESSAGE": "There was an error deleting the FAQ, please try again."
|
||||
},
|
||||
"SUGGESTIONS_BANNER": {
|
||||
"TITLE": "Captain grouped recurring customer questions into FAQ suggestions.",
|
||||
"ACTION": "Review suggestions"
|
||||
"FILTER": {
|
||||
"ASSISTANT": "Assistant: {selected}",
|
||||
"STATUS": "Status: {selected}",
|
||||
"ALL_ASSISTANTS": "All"
|
||||
},
|
||||
"STATUS": {
|
||||
"TITLE": "Status",
|
||||
"PENDING": "Pending",
|
||||
"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": {
|
||||
@@ -1147,6 +1161,7 @@
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No FAQs Found",
|
||||
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
|
||||
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
|
||||
"CLEAR_SEARCH": "Clear active filters",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
@@ -1155,43 +1170,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"FAQ_SUGGESTIONS": {
|
||||
"HEADER": "FAQ suggestions",
|
||||
"SEARCH_PLACEHOLDER": "Search suggestions...",
|
||||
"QUEUE_COUNT": "{count} suggestion to review | {count} suggestions to review",
|
||||
"QUEUE_HINT": "Ranked by customer demand",
|
||||
"SOURCE_COUNT": "{count} conversation | {count} conversations",
|
||||
"REVIEW": "Review sources",
|
||||
"APPROVE": "Approve",
|
||||
"APPROVE_FAQ": "Approve FAQ",
|
||||
"DISMISS": "Dismiss",
|
||||
"SAVE": "Save changes",
|
||||
"DETAILS": {
|
||||
"TITLE": "Review FAQ suggestion",
|
||||
"DESCRIPTION": "Captain grouped {count} conversation into this suggestion. Review the evidence and refine the FAQ before approval. | Captain grouped {count} conversations into this suggestion. Review the evidence and refine the FAQ before approval.",
|
||||
"SOURCES": "Source conversations",
|
||||
"SOURCE_PROGRESS": "{visible} of {total}",
|
||||
"NO_SOURCES": "No accessible source conversations were found.",
|
||||
"RETRY": "Retry"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "The review queue is clear",
|
||||
"SUBTITLE": "New reusable questions will appear here after Captain groups similar resolved conversations.",
|
||||
"CLEAR_SEARCH": "Clear search"
|
||||
},
|
||||
"SUCCESS": {
|
||||
"APPROVED": "FAQ approved and added to Captain's knowledge.",
|
||||
"DISMISSED": "Suggestion dismissed.",
|
||||
"SAVED": "Suggestion updated."
|
||||
},
|
||||
"ERRORS": {
|
||||
"LOAD": "There was an error loading FAQ suggestions. Please try again.",
|
||||
"LOAD_DETAILS": "There was an error loading the source conversations.",
|
||||
"APPROVE": "There was an error approving the FAQ. Please try again.",
|
||||
"DISMISS": "There was an error dismissing the suggestion. Please try again.",
|
||||
"SAVE": "There was an error saving the suggestion. Please try again."
|
||||
}
|
||||
},
|
||||
"INBOXES": {
|
||||
"HEADER": "Connected Inboxes",
|
||||
"ADD_NEW": "Connect a new inbox",
|
||||
|
||||
@@ -26,28 +26,25 @@ const canDrilldown = computed(() => checkPermissions(['administrator']));
|
||||
const selectedRange = ref('this_month');
|
||||
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const metricStats = ref(null);
|
||||
const faqStats = ref(null);
|
||||
const isFetchingMetrics = ref(false);
|
||||
const stats = ref(null);
|
||||
const isFetching = ref(false);
|
||||
|
||||
// Increments on every fetch so a response (or retry) from a superseded
|
||||
// range/assistant can't clobber the latest request's state.
|
||||
let metricsFetchToken = 0;
|
||||
let faqStatsFetchToken = 0;
|
||||
let metricsAbortController = null;
|
||||
let faqStatsAbortController = null;
|
||||
let fetchToken = 0;
|
||||
let abortController = null;
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
metricsFetchToken += 1;
|
||||
const token = metricsFetchToken;
|
||||
metricsAbortController?.abort();
|
||||
metricsAbortController = new AbortController();
|
||||
const { signal } = metricsAbortController;
|
||||
metricStats.value = null;
|
||||
isFetchingMetrics.value = true;
|
||||
const fetchStats = async () => {
|
||||
fetchToken += 1;
|
||||
const token = fetchToken;
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
const { signal } = abortController;
|
||||
stats.value = null;
|
||||
isFetching.value = true;
|
||||
|
||||
const requestMetrics = () =>
|
||||
CaptainAssistant.getMetrics({
|
||||
const requestStats = () =>
|
||||
CaptainAssistant.getStats({
|
||||
assistantId: assistantId.value,
|
||||
range: selectedRange.value,
|
||||
signal,
|
||||
@@ -55,54 +52,25 @@ const fetchMetrics = async () => {
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
({ data } = await requestMetrics());
|
||||
({ data } = await requestStats());
|
||||
} catch {
|
||||
// One silent retry before giving up, unless the request was aborted.
|
||||
try {
|
||||
if (token === metricsFetchToken && !signal.aborted)
|
||||
({ data } = await requestMetrics());
|
||||
if (token === fetchToken && !signal.aborted)
|
||||
({ data } = await requestStats());
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (token !== metricsFetchToken || signal.aborted) return;
|
||||
metricStats.value = data;
|
||||
isFetchingMetrics.value = false;
|
||||
if (token !== fetchToken || signal.aborted) return;
|
||||
stats.value = data;
|
||||
isFetching.value = false;
|
||||
};
|
||||
|
||||
const fetchFaqStats = async () => {
|
||||
faqStatsFetchToken += 1;
|
||||
const token = faqStatsFetchToken;
|
||||
faqStatsAbortController?.abort();
|
||||
faqStatsAbortController = new AbortController();
|
||||
const { signal } = faqStatsAbortController;
|
||||
faqStats.value = null;
|
||||
onUnmounted(() => abortController?.abort());
|
||||
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getFaqStats({
|
||||
assistantId: assistantId.value,
|
||||
signal,
|
||||
});
|
||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = data;
|
||||
} catch {
|
||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const summaryStats = computed(() => {
|
||||
if (!metricStats.value || !faqStats.value) return null;
|
||||
|
||||
return { ...metricStats.value, knowledge: faqStats.value };
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
metricsAbortController?.abort();
|
||||
faqStatsAbortController?.abort();
|
||||
});
|
||||
|
||||
watch([selectedRange, assistantId], fetchMetrics, { immediate: true });
|
||||
watch(assistantId, fetchFaqStats, { immediate: true });
|
||||
watch([selectedRange, assistantId], fetchStats, { immediate: true });
|
||||
|
||||
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
|
||||
// neutral, so we can colour the delta independently of its sign.
|
||||
@@ -122,7 +90,7 @@ const formatDuration = hours =>
|
||||
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
|
||||
|
||||
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
|
||||
const data = metricStats.value?.[statKey];
|
||||
const data = stats.value?.[statKey];
|
||||
if (!data) return { value: '—', trend: '', trendGood: null };
|
||||
|
||||
const sign = data.trend > 0 ? '+' : '';
|
||||
@@ -216,9 +184,9 @@ const closeDrilldown = () => {
|
||||
<div class="flex flex-col gap-6 pb-8">
|
||||
<InboxBanner />
|
||||
|
||||
<CoverageBanner :knowledge="faqStats ?? undefined" />
|
||||
<CoverageBanner :knowledge="stats?.knowledge" />
|
||||
|
||||
<WelcomeCard :range="selectedRange" :stats="summaryStats" />
|
||||
<WelcomeCard :range="selectedRange" :stats="stats" />
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
|
||||
@@ -231,15 +199,13 @@ const closeDrilldown = () => {
|
||||
:trend="metric.trend"
|
||||
:hint="metric.hint"
|
||||
:trend-good="metric.trendGood"
|
||||
:loading="isFetchingMetrics"
|
||||
:clickable="
|
||||
canDrilldown && Boolean(metric.metric) && !isFetchingMetrics
|
||||
"
|
||||
:loading="isFetching"
|
||||
:clickable="canDrilldown && Boolean(metric.metric) && !isFetching"
|
||||
@click="openDrilldown(metric)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<KnowledgeCard :knowledge="faqStats ?? undefined" />
|
||||
<KnowledgeCard :knowledge="stats?.knowledge" />
|
||||
|
||||
<QuickLinks />
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import {
|
||||
CONVERSATION_PERMISSIONS,
|
||||
ROLES,
|
||||
} from 'dashboard/constants/permissions';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
|
||||
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
|
||||
@@ -19,7 +15,7 @@ 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 FaqSuggestionsIndex from './responses/FaqSuggestions.vue';
|
||||
import ResponsesPendingIndex from './responses/Pending.vue';
|
||||
import CustomToolsIndex from './tools/Index.vue';
|
||||
|
||||
const meta = {
|
||||
@@ -28,11 +24,6 @@ const meta = {
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
|
||||
};
|
||||
|
||||
const faqSuggestionsMeta = {
|
||||
...meta,
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
};
|
||||
|
||||
const metaCustomTools = {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS,
|
||||
@@ -88,21 +79,11 @@ const assistantRoutes = [
|
||||
name: 'captain_assistants_inboxes_index',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL(
|
||||
'accounts/:accountId/captain/:assistantId/faqs/suggestions'
|
||||
),
|
||||
component: FaqSuggestionsIndex,
|
||||
name: 'captain_assistants_faq_suggestions',
|
||||
meta: faqSuggestionsMeta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
|
||||
redirect: to => ({
|
||||
name: 'captain_assistants_faq_suggestions',
|
||||
params: to.params,
|
||||
query: to.query,
|
||||
}),
|
||||
component: ResponsesPendingIndex,
|
||||
name: 'captain_assistants_responses_pending',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils';
|
||||
import FaqSuggestions from './FaqSuggestions.vue';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
route: null,
|
||||
getterValues: null,
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/api/captain/faqSuggestions', () => ({
|
||||
default: { get: mocks.apiGet },
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store', async () => {
|
||||
const { ref } = await import('vue');
|
||||
mocks.getterValues = {
|
||||
'captainFaqSuggestions/getRecords': ref([]),
|
||||
'captainFaqSuggestions/getMeta': ref({ totalCount: 0, page: 1 }),
|
||||
'captainFaqSuggestions/getUIFlags': ref({
|
||||
fetchingList: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
useStore: () => ({ dispatch: mocks.dispatch }),
|
||||
useMapGetter: key => mocks.getterValues[key],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
const { reactive } = await import('vue');
|
||||
mocks.route = reactive({
|
||||
params: { accountId: 1, assistantId: 1 },
|
||||
query: {},
|
||||
});
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => mocks.route,
|
||||
useRouter: () => ({ replace: mocks.replace }),
|
||||
};
|
||||
});
|
||||
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const PageLayoutStub = {
|
||||
template: '<div><slot name="body" /></div>',
|
||||
};
|
||||
|
||||
const FaqSuggestionCardStub = {
|
||||
props: ['suggestion'],
|
||||
template: '<div>{{ suggestion.question }}</div>',
|
||||
};
|
||||
|
||||
describe('FaqSuggestions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.route.params.assistantId = 1;
|
||||
mocks.route.query = {};
|
||||
mocks.getterValues['captainFaqSuggestions/getRecords'].value = [];
|
||||
mocks.getterValues['captainFaqSuggestions/getMeta'].value = {
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
};
|
||||
mocks.dispatch.mockImplementation((action, payload) => {
|
||||
if (action !== 'captainFaqSuggestions/setRecords') return;
|
||||
|
||||
mocks.getterValues['captainFaqSuggestions/getRecords'].value =
|
||||
payload.records;
|
||||
mocks.getterValues['captainFaqSuggestions/getMeta'].value = {
|
||||
totalCount: payload.meta.total_count,
|
||||
page: payload.meta.page,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the latest assistant results when an older request is superseded', async () => {
|
||||
const firstRequest = deferred();
|
||||
const secondRequest = deferred();
|
||||
const queued = [firstRequest, secondRequest];
|
||||
|
||||
mocks.apiGet.mockImplementation(({ signal }) => {
|
||||
const request = queued.shift();
|
||||
signal.addEventListener('abort', () => {
|
||||
const error = new Error('canceled');
|
||||
error.name = 'CanceledError';
|
||||
request.reject(error);
|
||||
});
|
||||
return request.promise;
|
||||
});
|
||||
|
||||
const wrapper = shallowMount(FaqSuggestions, {
|
||||
global: {
|
||||
mocks: { $t: key => key },
|
||||
stubs: {
|
||||
PageLayout: PageLayoutStub,
|
||||
FaqSuggestionCard: FaqSuggestionCardStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
// Switching assistants aborts the first request before it resolves.
|
||||
mocks.route.params.assistantId = 2;
|
||||
await flushPromises();
|
||||
|
||||
secondRequest.resolve({
|
||||
data: {
|
||||
payload: [{ id: 2, question: 'Current assistant' }],
|
||||
meta: { page: 1, total_count: 1 },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('Current assistant');
|
||||
expect(wrapper.text()).not.toContain('Previous assistant');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,259 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import CaptainFaqSuggestionsAPI from 'dashboard/api/captain/faqSuggestions';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import FaqSuggestionCard from 'dashboard/components-next/captain/assistant/FaqSuggestionCard.vue';
|
||||
import FaqSuggestionReviewDialog from 'dashboard/components-next/captain/pageComponents/response/FaqSuggestionReviewDialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const suggestions = useMapGetter('captainFaqSuggestions/getRecords');
|
||||
const suggestionMeta = useMapGetter('captainFaqSuggestions/getMeta');
|
||||
const uiFlags = useMapGetter('captainFaqSuggestions/getUIFlags');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedSuggestion = ref(null);
|
||||
const reviewDialog = ref(null);
|
||||
const activeSuggestionId = ref(null);
|
||||
|
||||
const selectedAssistantId = computed(() => Number(route.params.assistantId));
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const isMutating = computed(
|
||||
() => uiFlags.value.updatingItem || uiFlags.value.deletingItem
|
||||
);
|
||||
const hasActiveFilters = computed(() => Boolean(searchQuery.value));
|
||||
|
||||
const backUrl = computed(() => ({
|
||||
name: 'captain_assistants_responses_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: selectedAssistantId.value,
|
||||
},
|
||||
}));
|
||||
|
||||
const updateURL = (page, search) => {
|
||||
const query = { page: page || 1 };
|
||||
if (search) query.search = search;
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
const { run: runListRequest, abort: abortListRequest } = useAbortableRequest();
|
||||
|
||||
const fetchSuggestions = async (page = 1) => {
|
||||
updateURL(page, searchQuery.value);
|
||||
store.dispatch('captainFaqSuggestions/setFetchingList', true);
|
||||
|
||||
try {
|
||||
const response = await runListRequest(signal =>
|
||||
CaptainFaqSuggestionsAPI.get({
|
||||
page,
|
||||
search: searchQuery.value,
|
||||
assistantId: selectedAssistantId.value,
|
||||
signal,
|
||||
})
|
||||
);
|
||||
|
||||
if (!response) return;
|
||||
|
||||
store.dispatch('captainFaqSuggestions/setRecords', {
|
||||
records: response.data.payload,
|
||||
meta: response.data.meta,
|
||||
});
|
||||
store.dispatch('captainFaqSuggestions/setFetchingList', false);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD'));
|
||||
store.dispatch('captainFaqSuggestions/setFetchingList', false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshCurrentPage = () => {
|
||||
const currentPage = suggestionMeta.value.page || 1;
|
||||
const page =
|
||||
suggestions.value.length || currentPage === 1
|
||||
? currentPage
|
||||
: currentPage - 1;
|
||||
fetchSuggestions(page);
|
||||
};
|
||||
|
||||
const handleApprove = async suggestion => {
|
||||
activeSuggestionId.value = suggestion.id;
|
||||
try {
|
||||
await store.dispatch('captainFaqSuggestions/approve', {
|
||||
id: suggestion.id,
|
||||
question: suggestion.question,
|
||||
answer: suggestion.answer,
|
||||
});
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.APPROVED'));
|
||||
refreshCurrentPage();
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.APPROVE'));
|
||||
} finally {
|
||||
activeSuggestionId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = async suggestion => {
|
||||
activeSuggestionId.value = suggestion.id;
|
||||
try {
|
||||
await store.dispatch('captainFaqSuggestions/dismiss', suggestion.id);
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.DISMISSED'));
|
||||
refreshCurrentPage();
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.DISMISS'));
|
||||
} finally {
|
||||
activeSuggestionId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReview = suggestion => {
|
||||
selectedSuggestion.value = suggestion;
|
||||
nextTick(() => reviewDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleReviewClose = () => {
|
||||
selectedSuggestion.value = null;
|
||||
};
|
||||
|
||||
const handleResolved = () => {
|
||||
refreshCurrentPage();
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(() => fetchSuggestions(1), 500);
|
||||
|
||||
const handleSearchInput = () => {
|
||||
abortListRequest();
|
||||
debouncedSearch();
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
searchQuery.value = '';
|
||||
fetchSuggestions(1);
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
searchQuery.value = route.query.search || '';
|
||||
fetchSuggestions(parseInt(route.query.page, 10) || 1);
|
||||
};
|
||||
|
||||
watch(
|
||||
selectedAssistantId,
|
||||
() => {
|
||||
selectedSuggestion.value = null;
|
||||
activeSuggestionId.value = null;
|
||||
store.dispatch('captainFaqSuggestions/setRecords', {
|
||||
records: [],
|
||||
meta: { page: 1, total_count: 0 },
|
||||
});
|
||||
initializeFromURL();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
store.dispatch('captainFaqSuggestions/setFetchingList', false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:total-count="suggestionMeta.totalCount"
|
||||
:current-page="suggestionMeta.page"
|
||||
:header-title="$t('CAPTAIN.FAQ_SUGGESTIONS.HEADER')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!suggestions.length"
|
||||
:show-pagination-footer="!isFetching && !!suggestions.length"
|
||||
:show-know-more="false"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:back-url="backUrl"
|
||||
@update:current-page="fetchSuggestions"
|
||||
>
|
||||
<template #search>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('CAPTAIN.FAQ_SUGGESTIONS.SEARCH_PLACEHOLDER')"
|
||||
class="w-64"
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #subHeader>
|
||||
<div
|
||||
v-if="suggestions.length"
|
||||
class="mb-2 flex items-center gap-2 text-sm text-n-slate-11"
|
||||
>
|
||||
<span class="font-medium text-n-slate-12">
|
||||
{{
|
||||
$t('CAPTAIN.FAQ_SUGGESTIONS.QUEUE_COUNT', {
|
||||
count: suggestionMeta.totalCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="border-s border-n-weak ps-2">
|
||||
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.QUEUE_HINT') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.FAQ_SUGGESTIONS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.FAQ_SUGGESTIONS.EMPTY_STATE.SUBTITLE')"
|
||||
:show-backdrop="false"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.EMPTY_STATE.CLEAR_SEARCH')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="clearFilters"
|
||||
/>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4 pb-6">
|
||||
<FaqSuggestionCard
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion.id"
|
||||
:suggestion="suggestion"
|
||||
:is-loading="isMutating && activeSuggestionId === suggestion.id"
|
||||
@approve="handleApprove"
|
||||
@dismiss="handleDismiss"
|
||||
@review="handleReview"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<FaqSuggestionReviewDialog
|
||||
v-if="selectedSuggestion"
|
||||
ref="reviewDialog"
|
||||
:suggestion="selectedSuggestion"
|
||||
@close="handleReviewClose"
|
||||
@resolved="handleResolved"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -1,14 +1,11 @@
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref, nextTick, watch } from 'vue';
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
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 CaptainResponseAPI from 'dashboard/api/captain/response';
|
||||
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
@@ -44,7 +41,7 @@ const createDialog = ref(null);
|
||||
|
||||
const selectedAssistantId = computed(() => Number(route.params.assistantId));
|
||||
|
||||
const suggestionCount = useMapGetter('captainFaqSuggestions/getOpenCount');
|
||||
const pendingCount = useMapGetter('captainResponses/getPendingCount');
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDialog.value.dialogRef.open();
|
||||
@@ -98,10 +95,8 @@ const updateURLWithFilters = (page, search) => {
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
const { run: runListRequest, abort: abortListRequest } = useAbortableRequest();
|
||||
|
||||
const fetchResponses = async (page = 1) => {
|
||||
const filterParams = { page };
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'approved' };
|
||||
|
||||
if (selectedAssistantId.value) {
|
||||
filterParams.assistantId = selectedAssistantId.value;
|
||||
@@ -113,24 +108,7 @@ const fetchResponses = async (page = 1) => {
|
||||
// Update URL with current filters
|
||||
updateURLWithFilters(page, searchQuery.value);
|
||||
|
||||
store.dispatch('captainResponses/setFetchingList', true);
|
||||
|
||||
try {
|
||||
const response = await runListRequest(signal =>
|
||||
CaptainResponseAPI.get({ ...filterParams, signal })
|
||||
);
|
||||
|
||||
if (!response) return;
|
||||
|
||||
store.dispatch('captainResponses/setRecords', {
|
||||
records: response.data.payload,
|
||||
meta: response.data.meta,
|
||||
});
|
||||
store.dispatch('captainResponses/setFetchingList', false);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.RESPONSES.ERRORS.LOAD'));
|
||||
store.dispatch('captainResponses/setFetchingList', false);
|
||||
}
|
||||
store.dispatch('captainResponses/get', filterParams);
|
||||
};
|
||||
|
||||
// Bulk action
|
||||
@@ -203,47 +181,24 @@ const debouncedSearch = debounce(async () => {
|
||||
fetchResponses(1);
|
||||
}, 500);
|
||||
|
||||
const handleSearchInput = () => {
|
||||
abortListRequest();
|
||||
debouncedSearch();
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
searchQuery.value = route.query.search || '';
|
||||
if (route.query.search) {
|
||||
searchQuery.value = route.query.search;
|
||||
}
|
||||
const pageFromURL = parseInt(route.query.page, 10) || 1;
|
||||
fetchResponses(pageFromURL);
|
||||
};
|
||||
|
||||
const navigateToFaqSuggestions = () => {
|
||||
router.push({
|
||||
name: 'captain_assistants_faq_suggestions',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: selectedAssistantId.value,
|
||||
},
|
||||
});
|
||||
const navigateToPendingFAQs = () => {
|
||||
router.push({ name: 'captain_assistants_responses_pending' });
|
||||
};
|
||||
|
||||
watch(
|
||||
selectedAssistantId,
|
||||
() => {
|
||||
selectedResponse.value = null;
|
||||
bulkSelectedIds.value = new Set();
|
||||
store.dispatch('captainResponses/setRecords', {
|
||||
records: [],
|
||||
meta: { page: 1, total_count: 0 },
|
||||
});
|
||||
initializeFromURL();
|
||||
store.dispatch(
|
||||
'captainFaqSuggestions/fetchOpenCount',
|
||||
selectedAssistantId.value
|
||||
);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
store.dispatch('captainResponses/setFetchingList', false);
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
store.dispatch(
|
||||
'captainResponses/fetchPendingCount',
|
||||
selectedAssistantId.value
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -285,7 +240,7 @@ onUnmounted(() => {
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="handleSearchInput"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -316,13 +271,13 @@ onUnmounted(() => {
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
<Banner
|
||||
v-if="suggestionCount > 0"
|
||||
v-if="pendingCount > 0"
|
||||
color="blue"
|
||||
class="mb-4 -mt-3"
|
||||
:action-label="$t('CAPTAIN.RESPONSES.SUGGESTIONS_BANNER.ACTION')"
|
||||
@action="navigateToFaqSuggestions"
|
||||
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
|
||||
@action="navigateToPendingFAQs"
|
||||
>
|
||||
{{ $t('CAPTAIN.RESPONSES.SUGGESTIONS_BANNER.TITLE') }}
|
||||
{{ $t('CAPTAIN.RESPONSES.PENDING_BANNER.TITLE') }}
|
||||
</Banner>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<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 Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.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 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 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 selectedAssistantId = computed(() => route.params.assistantId);
|
||||
const dialogType = ref('');
|
||||
const searchQuery = ref('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const createDialog = ref(null);
|
||||
|
||||
const backUrl = computed(() => ({
|
||||
name: 'captain_assistants_responses_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: selectedAssistantId.value,
|
||||
},
|
||||
}));
|
||||
|
||||
// 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 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 updateURLWithFilters = (page, search) => {
|
||||
const query = {
|
||||
page: page || 1,
|
||||
};
|
||||
|
||||
if (search) {
|
||||
query.search = search;
|
||||
}
|
||||
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'pending' };
|
||||
|
||||
if (selectedAssistantId.value) {
|
||||
filterParams.assistantId = selectedAssistantId.value;
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
filterParams.search = searchQuery.value;
|
||||
}
|
||||
|
||||
// Update URL with current filters
|
||||
updateURLWithFilters(page, searchQuery.value);
|
||||
|
||||
store.dispatch('captainResponses/get', filterParams);
|
||||
};
|
||||
|
||||
// Bulk action
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = filteredResponses.value?.length || 0;
|
||||
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
|
||||
return isAllSelected
|
||||
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() => {
|
||||
return t('CAPTAIN.RESPONSES.SELECTED', {
|
||||
count: bulkSelectedIds.value.size,
|
||||
});
|
||||
});
|
||||
|
||||
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 hadSelection = bulkSelectedIds.value.size > 0;
|
||||
|
||||
fetchResponses(page);
|
||||
|
||||
if (hadSelection) {
|
||||
bulkSelectedIds.value = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
onPageChange(responseMeta.value.page - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const onBulkDeleteSuccess = () => {
|
||||
fetchResponseAfterBulkAction();
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(async () => {
|
||||
fetchResponses(1);
|
||||
}, 500);
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return Boolean(searchQuery.value);
|
||||
});
|
||||
|
||||
const clearFilters = () => {
|
||||
searchQuery.value = '';
|
||||
fetchResponses(1);
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
if (route.query.search) {
|
||||
searchQuery.value = route.query.search;
|
||||
}
|
||||
const pageFromURL = parseInt(route.query.page, 10) || 1;
|
||||
fetchResponses(pageFromURL);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:total-count="responseMeta.totalCount"
|
||||
:current-page="responseMeta.page"
|
||||
:header-title="$t('CAPTAIN.RESPONSES.PENDING_FAQS')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!filteredResponses.length"
|
||||
:show-pagination-footer="!isFetching && !!filteredResponses.length"
|
||||
:show-know-more="false"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:back-url="backUrl"
|
||||
@update:current-page="onPageChange"
|
||||
>
|
||||
<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 #search>
|
||||
<div
|
||||
v-if="bulkSelectedIds.size === 0"
|
||||
class="flex gap-3 justify-between w-full items-center"
|
||||
>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
||||
class="w-64"
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #subHeader>
|
||||
<BulkSelectBar
|
||||
v-model="bulkSelectedIds"
|
||||
:all-items="filteredResponses"
|
||||
:select-all-label="buildSelectedCountLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
:delete-label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||
class="w-fit"
|
||||
:class="{
|
||||
'mb-2': bulkSelectedIds.size > 0,
|
||||
}"
|
||||
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
|
||||
>
|
||||
<template #secondaryActions>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
ghost
|
||||
icon="i-lucide-check"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkApprove"
|
||||
/>
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<ResponsePageEmptyState
|
||||
variant="pending"
|
||||
:has-active-filters="hasActiveFilters"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</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="AssistantResponse"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
|
||||
<CreateResponseDialog
|
||||
v-if="dialogType"
|
||||
ref="createDialog"
|
||||
:type="dialogType"
|
||||
:selected-response="selectedResponse"
|
||||
@close="handleCreateClose"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -10,6 +10,7 @@ const START_VALUE = {
|
||||
name: null,
|
||||
description: null,
|
||||
event_name: 'conversation_created',
|
||||
execution_delay: null,
|
||||
conditions: [
|
||||
{
|
||||
attribute_key: 'status',
|
||||
|
||||
+356
-76
@@ -6,6 +6,11 @@ import { useOperators } from 'dashboard/components-next/filter/operators';
|
||||
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
|
||||
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
||||
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import {
|
||||
generateAutomationPayload,
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
showActionInput,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
import { validateAutomation } from 'dashboard/helper/validations';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -71,6 +77,28 @@ const INPUT_TYPE_MAP = {
|
||||
date: 'date',
|
||||
};
|
||||
|
||||
const DEFAULT_DELAY_MINUTES = 240; // 4 hours
|
||||
const MIN_DELAY_MINUTES = 10;
|
||||
const MAX_DELAY_MINUTES = 43200; // 30 days
|
||||
// A delayed rule is expressed as one meaningful trigger instead of a raw event + conditions. Each
|
||||
// trigger maps to the automation's event_name plus a preset condition: message_type for the two
|
||||
// unresponsive cases (reply-chase / awaiting-agent), or a chosen status for conversation_updated.
|
||||
const DELAYED_TRIGGERS = [
|
||||
{ key: 'conversation_status', eventName: 'conversation_updated' },
|
||||
{
|
||||
key: 'customer_unresponsive',
|
||||
eventName: 'message_created',
|
||||
messageType: 'outgoing',
|
||||
},
|
||||
{
|
||||
key: 'agent_unresponsive',
|
||||
eventName: 'message_created',
|
||||
messageType: 'incoming',
|
||||
},
|
||||
];
|
||||
const DEFAULT_TRIGGER = DELAYED_TRIGGERS[0].key;
|
||||
const DEFAULT_TRIGGER_STATUS = 'pending';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const { operators } = useOperators();
|
||||
@@ -78,6 +106,7 @@ const { operators } = useOperators();
|
||||
const dialogRef = ref(null);
|
||||
const conditionsRef = useTemplateRef('conditionsRef');
|
||||
const errors = ref({});
|
||||
const isDelayed = ref(false);
|
||||
|
||||
const isEditMode = computed(() => props.mode === 'edit');
|
||||
|
||||
@@ -184,6 +213,172 @@ const hasActionErrors = computed(() =>
|
||||
Object.keys(errors.value).some(key => key.startsWith('action_'))
|
||||
);
|
||||
|
||||
const allowsDelayedExecution = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.DELAYED_AUTOMATIONS)
|
||||
);
|
||||
|
||||
// Trigger controls for the delayed flow. They own event_name + conditions while the wait is on.
|
||||
// selectedTrigger / triggerStatus are plain values (FilterSelect); triggerInboxes is an array of
|
||||
// { id, name } (MultiSelect) — empty means the rule applies to every inbox.
|
||||
const selectedTrigger = ref(DEFAULT_TRIGGER);
|
||||
const triggerStatus = ref(DEFAULT_TRIGGER_STATUS);
|
||||
const triggerInboxes = ref([]);
|
||||
|
||||
const isStatusTrigger = computed(
|
||||
() => selectedTrigger.value === 'conversation_status'
|
||||
);
|
||||
|
||||
// FilterSelect expects { label, value }.
|
||||
const statusSelectOptions = computed(() =>
|
||||
(props.getConditionDropdownValues('status') || [])
|
||||
.filter(option => option.id !== 'all')
|
||||
.map(option => ({ value: option.id, label: option.name }))
|
||||
);
|
||||
|
||||
const triggerSelectOptions = computed(() =>
|
||||
DELAYED_TRIGGERS.map(trigger => ({
|
||||
value: trigger.key,
|
||||
label: t(
|
||||
`AUTOMATION.ADD.FORM.TRIGGER.OPTIONS.${trigger.key.toUpperCase()}`
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
// MultiSelect expects (and returns) { id, name } options.
|
||||
const inboxOptions = computed(
|
||||
() => props.getConditionDropdownValues('inbox_id') || []
|
||||
);
|
||||
|
||||
// What ends the wait, mirroring the backend episode that arms the rule. Shown to the user so
|
||||
// they can predict when the rule runs. Conversation rules key on status; message rules key on
|
||||
// the reply that ends the wait (customer reply for outgoing, agent reply for incoming).
|
||||
const waitEndsKey = computed(() => {
|
||||
if (eventName.value !== 'message_created') return 'STATUS';
|
||||
const messageType = (automation.value?.conditions || []).find(
|
||||
condition => condition.attribute_key === 'message_type'
|
||||
);
|
||||
const raw = Array.isArray(messageType?.values)
|
||||
? messageType.values[0]
|
||||
: messageType?.values;
|
||||
// Raw create-mode values are strings ('outgoing'); edit-mode values are option objects.
|
||||
const value = raw && typeof raw === 'object' ? raw.id : raw;
|
||||
if (value === 'outgoing') return 'CUSTOMER_REPLY';
|
||||
if (value === 'incoming') return 'AGENT_REPLY';
|
||||
return 'GENERIC';
|
||||
});
|
||||
|
||||
// DurationInput holds the wait in minutes and clamps to [MIN, MAX]; the unit is display-only.
|
||||
const delayMinutes = ref(DEFAULT_DELAY_MINUTES);
|
||||
const delayUnit = ref(DURATION_UNITS.HOURS);
|
||||
|
||||
const executionDelayInvalid = computed(
|
||||
() => isDelayed.value && !Number.isFinite(delayMinutes.value)
|
||||
);
|
||||
|
||||
// Show the wait in the largest whole unit (240 min → 4 hours). Passed in by open() rather than
|
||||
// read from `automation`, whose model prop only settles a tick later.
|
||||
const syncDelayFromDelay = delay => {
|
||||
isDelayed.value = Boolean(delay);
|
||||
const minutes = delay || DEFAULT_DELAY_MINUTES;
|
||||
if (minutes % 1440 === 0) delayUnit.value = DURATION_UNITS.DAYS;
|
||||
else if (minutes % 60 === 0) delayUnit.value = DURATION_UNITS.HOURS;
|
||||
else delayUnit.value = DURATION_UNITS.MINUTES;
|
||||
delayMinutes.value = minutes;
|
||||
};
|
||||
|
||||
watch([isDelayed, delayMinutes], () => {
|
||||
if (!automation.value || !allowsDelayedExecution.value) return;
|
||||
automation.value.execution_delay = isDelayed.value
|
||||
? delayMinutes.value
|
||||
: null;
|
||||
});
|
||||
|
||||
const buildTriggerCondition = (attributeKey, values) => ({
|
||||
attribute_key: attributeKey,
|
||||
filter_operator: 'equal_to',
|
||||
values,
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
});
|
||||
|
||||
// Write the selected trigger (plus optional inbox scope) onto the rule's event_name + conditions.
|
||||
const applyDelayedTrigger = () => {
|
||||
const trigger = DELAYED_TRIGGERS.find(
|
||||
item => item.key === selectedTrigger.value
|
||||
);
|
||||
if (!automation.value || !trigger) return;
|
||||
automation.value.event_name = trigger.eventName;
|
||||
const conditions = [
|
||||
trigger.messageType
|
||||
? buildTriggerCondition('message_type', trigger.messageType)
|
||||
: buildTriggerCondition('status', triggerStatus.value),
|
||||
];
|
||||
// A private note is an outgoing message, so without this an internal note would read as a reply
|
||||
// and arm the customer-unresponsive wait. Incoming messages are never private.
|
||||
if (trigger.messageType === 'outgoing') {
|
||||
conditions.push(buildTriggerCondition('private_note', [false]));
|
||||
}
|
||||
if (triggerInboxes.value.length) {
|
||||
conditions.push(
|
||||
buildTriggerCondition(
|
||||
'inbox_id',
|
||||
triggerInboxes.value.map(inbox => inbox.id)
|
||||
)
|
||||
);
|
||||
}
|
||||
automation.value.conditions = conditions;
|
||||
};
|
||||
|
||||
// A single value is a raw string in create mode and an option object ({ id }) after edit-mode
|
||||
// formatting; return its plain value either way.
|
||||
const rawConditionValue = condition => {
|
||||
const raw = Array.isArray(condition?.values)
|
||||
? condition.values[0]
|
||||
: condition?.values;
|
||||
return raw && typeof raw === 'object' ? raw.id : raw;
|
||||
};
|
||||
|
||||
// Populate the trigger controls from an existing delayed rule when editing.
|
||||
const hydrateTriggerFromAutomation = () => {
|
||||
const conditions = automation.value?.conditions || [];
|
||||
const byKey = key => conditions.find(c => c.attribute_key === key);
|
||||
const messageType = rawConditionValue(byKey('message_type'));
|
||||
if (messageType === 'incoming') selectedTrigger.value = 'agent_unresponsive';
|
||||
else if (messageType === 'outgoing')
|
||||
selectedTrigger.value = 'customer_unresponsive';
|
||||
else {
|
||||
selectedTrigger.value = 'conversation_status';
|
||||
triggerStatus.value =
|
||||
rawConditionValue(byKey('status')) || DEFAULT_TRIGGER_STATUS;
|
||||
}
|
||||
const inboxValues = byKey('inbox_id')?.values || [];
|
||||
const inboxIds = inboxValues.map(value =>
|
||||
value && typeof value === 'object' ? value.id : value
|
||||
);
|
||||
triggerInboxes.value = inboxOptions.value.filter(inbox =>
|
||||
inboxIds.includes(inbox.id)
|
||||
);
|
||||
};
|
||||
|
||||
// Turning the wait on (create) sets the default trigger's event + conditions.
|
||||
watch(isDelayed, delayed => {
|
||||
if (delayed && automation.value && !isEditMode.value) applyDelayedTrigger();
|
||||
});
|
||||
|
||||
// Any trigger-control change re-derives event_name + conditions. After hydration this simply
|
||||
// re-writes the same values, so it stays idempotent (no reference change → no loop).
|
||||
watch([selectedTrigger, triggerStatus, triggerInboxes], () => {
|
||||
if (isDelayed.value) applyDelayedTrigger();
|
||||
});
|
||||
|
||||
// Opening an existing delayed rule mirrors its event/conditions into the trigger controls.
|
||||
watch(
|
||||
() => automation.value,
|
||||
() => {
|
||||
if (isDelayed.value && automation.value) hydrateTriggerFromAutomation();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => automation.value,
|
||||
() => {
|
||||
@@ -216,8 +411,9 @@ const syncCustomAttributeTypes = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
const open = (executionDelay = null) => {
|
||||
resetValidation();
|
||||
syncDelayFromDelay(executionDelay);
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
@@ -230,8 +426,13 @@ const emitSaveAutomation = () => {
|
||||
syncCustomAttributeTypes();
|
||||
const conditionsValid = isConditionsValid();
|
||||
errors.value = validateAutomation(automation.value);
|
||||
if (allowsDelayedExecution.value && executionDelayInvalid.value) {
|
||||
errors.value.execution_delay = true;
|
||||
}
|
||||
if (Object.keys(errors.value).length === 0 && conditionsValid) {
|
||||
const payload = generateAutomationPayload(automation.value);
|
||||
// The API rejects the param when the feature is off; existing values are kept server-side.
|
||||
if (!allowsDelayedExecution.value) delete payload.execution_delay;
|
||||
emit('save', payload, props.mode);
|
||||
}
|
||||
};
|
||||
@@ -266,84 +467,163 @@ defineExpose({ open, close });
|
||||
:error="errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="mb-6">
|
||||
<label :class="{ error: errors.event_name }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
|
||||
<select
|
||||
v-model="automation.event_name"
|
||||
class="m-0"
|
||||
@change="onEventChange()"
|
||||
>
|
||||
<option
|
||||
v-for="event in automationRuleEvents"
|
||||
:key="event.key"
|
||||
:value="event.key"
|
||||
>
|
||||
{{ event.value }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="errors.event_name" class="message">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
|
||||
<!-- Wait Start (choose the delay first, then the trigger) -->
|
||||
<div v-if="allowsDelayedExecution" class="mb-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<label class="mb-0" :class="{ error: errors.execution_delay }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.LABEL') }}
|
||||
</label>
|
||||
<ToggleSwitch v-model="isDelayed" />
|
||||
</div>
|
||||
<div v-if="isDelayed" class="flex flex-wrap items-center gap-2 mt-2">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.AFTER_DELAY') }}
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="!isEditMode && hasAutomationMutated"
|
||||
class="text-xs text-right text-n-teal-10 pt-1"
|
||||
>
|
||||
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Conditions Start -->
|
||||
<section class="mb-5">
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
|
||||
</label>
|
||||
<ul
|
||||
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
|
||||
:class="
|
||||
hasConditionErrors
|
||||
? 'outline-n-ruby-5 bg-n-ruby-2/50'
|
||||
: 'outline-n-weak dark:outline-n-strong'
|
||||
"
|
||||
>
|
||||
<template v-for="(condition, i) in automation.conditions" :key="i">
|
||||
<ConditionRow
|
||||
v-if="i === 0"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="automation.conditions[i].filter_operator"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="automation.conditions[i].filter_operator"
|
||||
v-model:query-operator="
|
||||
automation.conditions[i - 1].query_operator
|
||||
"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
show-query-operator
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
</template>
|
||||
<div>
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
|
||||
@click="appendNewCondition"
|
||||
<div class="flex items-center gap-2 w-64">
|
||||
<DurationInput
|
||||
v-model="delayMinutes"
|
||||
v-model:unit="delayUnit"
|
||||
:min="MIN_DELAY_MINUTES"
|
||||
:max="MAX_DELAY_MINUTES"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
<!-- Conditions End -->
|
||||
</div>
|
||||
<span
|
||||
v-if="isDelayed && executionDelayInvalid"
|
||||
class="text-xs text-n-ruby-9"
|
||||
>
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ERROR') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Wait End -->
|
||||
<!-- Delayed trigger: a curated event + condition, in place of raw Event/Conditions -->
|
||||
<div v-if="isDelayed" class="mb-6">
|
||||
<label class="mb-1">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
class="flex flex-col gap-3 p-4 outline outline-1 -outline-offset-1 rounded-xl outline-n-weak dark:outline-n-strong"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-h-8">
|
||||
<span class="w-20 shrink-0 text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.WHEN_LABEL') }}
|
||||
</span>
|
||||
<FilterSelect
|
||||
v-model="selectedTrigger"
|
||||
:options="triggerSelectOptions"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isStatusTrigger" class="flex items-center gap-3 min-h-8">
|
||||
<span class="w-20 shrink-0 text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.STATUS_LABEL') }}
|
||||
</span>
|
||||
<FilterSelect
|
||||
v-model="triggerStatus"
|
||||
:options="statusSelectOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 min-h-8">
|
||||
<span class="w-20 shrink-0 text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.INBOX_LABEL') }}
|
||||
</span>
|
||||
<MultiSelect v-model="triggerInboxes" :options="inboxOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-11 pt-2 mb-0">
|
||||
<span class="text-n-slate-12 font-medium">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF_LABEL') }}
|
||||
</span>
|
||||
{{ $t(`AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF.${waitEndsKey}`) }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11 pt-1 mb-0">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.HELP_TEXT') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Instant flow: raw Event + Conditions -->
|
||||
<template v-else>
|
||||
<div class="mb-6">
|
||||
<label :class="{ error: errors.event_name }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
|
||||
<select
|
||||
v-model="automation.event_name"
|
||||
class="m-0"
|
||||
@change="onEventChange()"
|
||||
>
|
||||
<option
|
||||
v-for="event in automationRuleEvents"
|
||||
:key="event.key"
|
||||
:value="event.key"
|
||||
>
|
||||
{{ event.value }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="errors.event_name" class="message">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="!isEditMode && hasAutomationMutated"
|
||||
class="text-xs text-right text-n-teal-10 pt-1"
|
||||
>
|
||||
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Conditions Start -->
|
||||
<section class="mb-5">
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
|
||||
</label>
|
||||
<ul
|
||||
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
|
||||
:class="
|
||||
hasConditionErrors
|
||||
? 'outline-n-ruby-5 bg-n-ruby-2/50'
|
||||
: 'outline-n-weak dark:outline-n-strong'
|
||||
"
|
||||
>
|
||||
<template v-for="(condition, i) in automation.conditions" :key="i">
|
||||
<ConditionRow
|
||||
v-if="i === 0"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="
|
||||
automation.conditions[i].filter_operator
|
||||
"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="
|
||||
automation.conditions[i].filter_operator
|
||||
"
|
||||
v-model:query-operator="
|
||||
automation.conditions[i - 1].query_operator
|
||||
"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
show-query-operator
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
</template>
|
||||
<div>
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
|
||||
@click="appendNewCondition"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
<!-- Conditions End -->
|
||||
</template>
|
||||
<!-- Actions Start -->
|
||||
<section>
|
||||
<label>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { messageStamp } from 'shared/helpers/timeHelper';
|
||||
import { formatDelay } from 'dashboard/helper/automationHelper';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
|
||||
@@ -43,6 +44,16 @@ const automationActive = computed({
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ automation.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="automation.execution_delay"
|
||||
class="text-xs px-1.5 py-0.5 rounded-md bg-n-alpha-2 text-n-slate-11 whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{{
|
||||
$t('AUTOMATION.LIST.DELAY_BADGE', {
|
||||
delay: formatDelay(automation.execution_delay),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
|
||||
<span class="text-body-main text-n-slate-11 truncate">
|
||||
{{ automation.description }}
|
||||
|
||||
+21
-17
@@ -34,29 +34,33 @@ const {
|
||||
|
||||
const { formatAutomation } = useEditableAutomation();
|
||||
|
||||
const open = () => formRef.value?.open();
|
||||
const syncAutomationFromSelected = (source = props.selectedResponse) => {
|
||||
if (!source?.conditions) return;
|
||||
|
||||
manifestCustomAttributes();
|
||||
automation.value = formatAutomation(
|
||||
source,
|
||||
allCustomAttributes.value,
|
||||
automationTypes,
|
||||
AUTOMATION_ACTION_TYPES
|
||||
);
|
||||
};
|
||||
|
||||
// Format from the rule passed to open(): the prop updates a tick later, so at open() time
|
||||
// automation still holds the previously selected rule (its execution_delay hydrates the form).
|
||||
const open = rule => {
|
||||
syncAutomationFromSelected(rule);
|
||||
formRef.value?.open(rule?.execution_delay);
|
||||
};
|
||||
const close = () => formRef.value?.close();
|
||||
|
||||
const onSave = (payload, mode) => {
|
||||
emit('saveAutomation', payload, mode);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.selectedResponse,
|
||||
value => {
|
||||
if (!value?.conditions) return;
|
||||
|
||||
manifestCustomAttributes();
|
||||
|
||||
automation.value = formatAutomation(
|
||||
value,
|
||||
allCustomAttributes.value,
|
||||
automationTypes,
|
||||
AUTOMATION_ACTION_TYPES
|
||||
);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(() => props.selectedResponse, syncAutomationFromSelected, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
@@ -35,6 +35,31 @@ const filteredRecords = computed(() => {
|
||||
if (!query) return records.value;
|
||||
return picoSearch(records.value, query, ['name', 'description']);
|
||||
});
|
||||
|
||||
// Delayed (wait) rules run on a different lifecycle, so list them in their own section.
|
||||
const hasDelayedRecords = computed(() =>
|
||||
records.value.some(automation => automation.execution_delay)
|
||||
);
|
||||
|
||||
const sections = computed(() => {
|
||||
const instant = [];
|
||||
const delayed = [];
|
||||
filteredRecords.value.forEach(automation =>
|
||||
(automation.execution_delay ? delayed : instant).push(automation)
|
||||
);
|
||||
return [
|
||||
{
|
||||
key: 'instant',
|
||||
label: t('AUTOMATION.LIST.SECTIONS.INSTANT'),
|
||||
items: instant,
|
||||
},
|
||||
{
|
||||
key: 'delayed',
|
||||
label: t('AUTOMATION.LIST.SECTIONS.DELAYED'),
|
||||
items: delayed,
|
||||
},
|
||||
].filter(section => section.items.length);
|
||||
});
|
||||
const uiFlags = computed(() => getters['automations/getUIFlags'].value);
|
||||
const accountId = computed(() => getters.getCurrentAccountId.value);
|
||||
|
||||
@@ -52,6 +77,14 @@ const isSLAEnabled = computed(() =>
|
||||
getters['accounts/isFeatureEnabledonAccount'].value(accountId.value, 'sla')
|
||||
);
|
||||
|
||||
const showDelayDisabledBanner = computed(
|
||||
() =>
|
||||
!getters['accounts/isFeatureEnabledonAccount'].value(
|
||||
accountId.value,
|
||||
'delayed_automations'
|
||||
) && records.value.some(automation => automation.execution_delay)
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('inboxes/get');
|
||||
store.dispatch('agents/get');
|
||||
@@ -74,7 +107,7 @@ const hideAddPopup = () => {
|
||||
|
||||
const openEditPopup = response => {
|
||||
selectedAutomation.value = { ...response };
|
||||
editDialogRef.value?.open();
|
||||
editDialogRef.value?.open(response);
|
||||
};
|
||||
const hideEditPopup = () => {
|
||||
editDialogRef.value?.close();
|
||||
@@ -128,11 +161,11 @@ const submitAutomation = async (payload, mode) => {
|
||||
hideAddPopup();
|
||||
hideEditPopup();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
const fallbackMessage =
|
||||
mode === 'edit'
|
||||
? t('AUTOMATION.EDIT.API.ERROR_MESSAGE')
|
||||
: t('AUTOMATION.ADD.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
useAlert(error?.response?.data?.error || fallbackMessage);
|
||||
}
|
||||
};
|
||||
const toggleAutomation = async ({ id, name, status }) => {
|
||||
@@ -212,25 +245,49 @@ const tableHeaders = computed(() => {
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
v-if="showDelayDisabledBanner"
|
||||
class="px-4 py-3 mb-4 text-sm rounded-lg bg-n-amber-3 text-n-amber-12"
|
||||
>
|
||||
{{ $t('AUTOMATION.LIST.DELAY_DISABLED_BANNER') }}
|
||||
</div>
|
||||
<template v-if="filteredRecords.length">
|
||||
<div
|
||||
v-for="section in sections"
|
||||
:key="section.key"
|
||||
class="mb-6 last:mb-0"
|
||||
>
|
||||
<h4
|
||||
v-if="hasDelayedRecords"
|
||||
class="mb-2 text-sm font-medium text-n-slate-11"
|
||||
>
|
||||
{{ section.label }}
|
||||
</h4>
|
||||
<BaseTable :headers="tableHeaders" :items="section.items">
|
||||
<template #row="{ items }">
|
||||
<AutomationRuleRow
|
||||
v-for="automation in items"
|
||||
:key="automation.id"
|
||||
:automation="automation"
|
||||
:loading="loading[automation.id]"
|
||||
@clone="cloneAutomation"
|
||||
@toggle="toggleAutomation"
|
||||
@edit="openEditPopup"
|
||||
@delete="openDeletePopup"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</template>
|
||||
<BaseTable
|
||||
v-else
|
||||
:headers="tableHeaders"
|
||||
:items="filteredRecords"
|
||||
:items="[]"
|
||||
:no-data-message="
|
||||
searchQuery ? $t('AUTOMATION.NO_RESULTS') : $t('AUTOMATION.LIST.404')
|
||||
"
|
||||
>
|
||||
<template #row="{ items }">
|
||||
<AutomationRuleRow
|
||||
v-for="automation in items"
|
||||
:key="automation.id"
|
||||
:automation="automation"
|
||||
:loading="loading[automation.id]"
|
||||
@clone="cloneAutomation"
|
||||
@toggle="toggleAutomation"
|
||||
@edit="openEditPopup"
|
||||
@delete="openDeletePopup"
|
||||
/>
|
||||
</template>
|
||||
<template #row />
|
||||
</BaseTable>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
@@ -44,9 +46,11 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
|
||||
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
|
||||
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Banner,
|
||||
BotConfiguration,
|
||||
CollaboratorsPage,
|
||||
ConfigurationPage,
|
||||
@@ -80,6 +84,7 @@ export default {
|
||||
WhatsappManualMigrationBanner,
|
||||
Widget,
|
||||
AccessToken,
|
||||
Icon,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
@@ -343,6 +348,12 @@ export default {
|
||||
instagramUnauthorized() {
|
||||
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
showInstagramRestrictionSettingsBanner() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
metaRestrictionStatusUrl() {
|
||||
return META_RESTRICTION_STATUS_URL;
|
||||
},
|
||||
tiktokUnauthorized() {
|
||||
return this.isATiktokChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
@@ -809,6 +820,29 @@ export default {
|
||||
:class="bannerMaxWidth"
|
||||
@start="openWhatsAppManualMigrationDialog"
|
||||
/>
|
||||
<Banner
|
||||
v-if="showInstagramRestrictionSettingsBanner"
|
||||
color="amber"
|
||||
class="mx-6 mb-4 max-w-4xl"
|
||||
>
|
||||
<div class="flex items-start gap-3 text-start">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
|
||||
<a
|
||||
:href="metaRestrictionStatusUrl"
|
||||
class="link underline"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<div
|
||||
v-if="selectedTabKey === 'inbox-settings'"
|
||||
|
||||
@@ -48,6 +48,20 @@ export default createStore({
|
||||
return response;
|
||||
},
|
||||
|
||||
handleBulkApprove: async function handleBulkApprove({ dispatch }, ids) {
|
||||
const response = await dispatch('processBulkAction', {
|
||||
type: 'AssistantResponse',
|
||||
actionType: 'approve',
|
||||
ids,
|
||||
});
|
||||
|
||||
// Update response store after successful API call
|
||||
await dispatch('captainResponses/updateBulkResponses', response, {
|
||||
root: true,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
handleBulkSync: async function handleBulkSync({ dispatch }, { ids }) {
|
||||
const response = await dispatch('processBulkAction', {
|
||||
type: 'AssistantDocument',
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import CaptainFaqSuggestionsAPI from 'dashboard/api/captain/faqSuggestions';
|
||||
import { createStore } from '../storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
const SET_OPEN_COUNT = 'SET_OPEN_COUNT';
|
||||
let openCountRequestId = 0;
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainFaqSuggestion',
|
||||
API: CaptainFaqSuggestionsAPI,
|
||||
getters: {
|
||||
getRecords: state => state.records,
|
||||
getOpenCount: state => state.meta.openCount || 0,
|
||||
},
|
||||
mutations: {
|
||||
[SET_OPEN_COUNT](state, count) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
openCount: Number(count),
|
||||
};
|
||||
},
|
||||
},
|
||||
actions: mutations => ({
|
||||
setFetchingList({ commit }, isFetching) {
|
||||
commit(mutations.SET_UI_FLAG, { fetchingList: isFetching });
|
||||
},
|
||||
setRecords({ commit }, { records, meta }) {
|
||||
commit(mutations.SET, records);
|
||||
commit(mutations.SET_META, meta);
|
||||
},
|
||||
approve: async ({ commit }, { id, question, answer }) => {
|
||||
commit(mutations.SET_UI_FLAG, { updatingItem: true });
|
||||
try {
|
||||
const response = await CaptainFaqSuggestionsAPI.approve(id, {
|
||||
question,
|
||||
answer,
|
||||
});
|
||||
commit(mutations.DELETE, id);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutations.SET_UI_FLAG, { updatingItem: false });
|
||||
}
|
||||
},
|
||||
dismiss: async ({ commit }, id) => {
|
||||
commit(mutations.SET_UI_FLAG, { deletingItem: true });
|
||||
try {
|
||||
await CaptainFaqSuggestionsAPI.dismiss(id);
|
||||
commit(mutations.DELETE, id);
|
||||
return id;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutations.SET_UI_FLAG, { deletingItem: false });
|
||||
}
|
||||
},
|
||||
fetchOpenCount: async ({ commit }, assistantId) => {
|
||||
openCountRequestId += 1;
|
||||
const requestId = openCountRequestId;
|
||||
commit(SET_OPEN_COUNT, 0);
|
||||
|
||||
try {
|
||||
const response = await CaptainFaqSuggestionsAPI.get({
|
||||
assistantId,
|
||||
page: 1,
|
||||
});
|
||||
if (requestId !== openCountRequestId) return;
|
||||
|
||||
commit(SET_OPEN_COUNT, response.data?.meta?.total_count || 0);
|
||||
} catch {
|
||||
if (requestId === openCountRequestId) commit(SET_OPEN_COUNT, 0);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -1,22 +1,58 @@
|
||||
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 => ({
|
||||
setFetchingList({ commit }, isFetching) {
|
||||
commit(mutations.SET_UI_FLAG, { fetchingList: isFetching });
|
||||
},
|
||||
setRecords({ commit }, { records, meta }) {
|
||||
commit(mutations.SET, records);
|
||||
commit(mutations.SET_META, meta);
|
||||
},
|
||||
removeBulkResponses: ({ commit, state }, ids) => {
|
||||
const updatedRecords = state.records.filter(
|
||||
record => !ids.includes(record.id)
|
||||
);
|
||||
commit(mutations.SET, updatedRecords);
|
||||
},
|
||||
updateBulkResponses: ({ commit, state }, approvedResponses) => {
|
||||
// Create a map of updated responses for faster lookup
|
||||
const updatedResponsesMap = approvedResponses.reduce((map, response) => {
|
||||
map[response.id] = response;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
// Update existing records with updated data
|
||||
const updatedRecords = state.records.map(record => {
|
||||
if (updatedResponsesMap[record.id]) {
|
||||
return updatedResponsesMap[record.id]; // Replace with the updated response
|
||||
}
|
||||
return record;
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -54,7 +54,6 @@ import captainAgentSessions from './captain/agentSessions';
|
||||
import captainAssistants from './captain/assistant';
|
||||
import captainDocuments from './captain/document';
|
||||
import captainResponses from './captain/response';
|
||||
import captainFaqSuggestions from './captain/faqSuggestions';
|
||||
import captainInboxes from './captain/inboxes';
|
||||
import captainBulkActions from './captain/bulkActions';
|
||||
import copilotThreads from './captain/copilotThreads';
|
||||
@@ -121,7 +120,6 @@ export default createStore({
|
||||
captainAssistants,
|
||||
captainDocuments,
|
||||
captainResponses,
|
||||
captainFaqSuggestions,
|
||||
captainInboxes,
|
||||
captainBulkActions,
|
||||
copilotThreads,
|
||||
|
||||
@@ -11,8 +11,6 @@ import { IFrameHelper } from '../helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
const TRANSCRIPT_COOLDOWN_MS = 15000;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatInputWrap,
|
||||
@@ -26,9 +24,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
inReplyTo: null,
|
||||
isSendingTranscript: false,
|
||||
transcriptCooldown: false,
|
||||
transcriptCooldownTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -62,9 +57,6 @@ export default {
|
||||
mounted() {
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearTimeout(this.transcriptCooldownTimer);
|
||||
},
|
||||
methods: {
|
||||
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
|
||||
...mapActions('conversationAttributes', ['getAttributes']),
|
||||
@@ -98,35 +90,19 @@ export default {
|
||||
toggleReplyTo(message) {
|
||||
this.inReplyTo = message;
|
||||
},
|
||||
startTranscriptCooldown() {
|
||||
this.transcriptCooldown = true;
|
||||
clearTimeout(this.transcriptCooldownTimer);
|
||||
this.transcriptCooldownTimer = setTimeout(() => {
|
||||
this.transcriptCooldown = false;
|
||||
}, TRANSCRIPT_COOLDOWN_MS);
|
||||
},
|
||||
async sendTranscript() {
|
||||
if (
|
||||
!this.hasEmail ||
|
||||
this.isSendingTranscript ||
|
||||
this.transcriptCooldown
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.isSendingTranscript = true;
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
this.startTranscriptCooldown();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
} finally {
|
||||
this.isSendingTranscript = false;
|
||||
if (this.hasEmail) {
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.$emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -168,7 +144,6 @@ export default {
|
||||
v-if="showEmailTranscriptButton"
|
||||
type="clear"
|
||||
class="font-normal"
|
||||
:disabled="isSendingTranscript || transcriptCooldown"
|
||||
@click="sendTranscript"
|
||||
>
|
||||
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
class AutomationRules::ProcessPendingExecutionJob < ApplicationJob
|
||||
queue_as :medium
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(pending_execution)
|
||||
return if delayed_automations_disabled?
|
||||
# Account flag off pauses (not skips): leave the row pending so re-enabling resumes it.
|
||||
return unless pending_execution.account.feature_enabled?('delayed_automations')
|
||||
# Atomic claim: a duplicate enqueue (overlapping sweep or stale reclaim) loses here and returns.
|
||||
return unless pending_execution.claim!
|
||||
|
||||
skip_reason = skip_reason_for(pending_execution)
|
||||
return pending_execution.update!(status: :skipped, skip_reason: skip_reason) if skip_reason
|
||||
|
||||
execute(pending_execution)
|
||||
rescue StandardError => e
|
||||
# Row stays `processing`; the next sweep reclaims and retries it once the lock goes stale.
|
||||
ChatwootExceptionTracker.new(e, account: pending_execution.account).capture_exception
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def skip_reason_for(pending_execution)
|
||||
return 'expired' if pending_execution.due_at < AutomationRulePendingExecution::DUE_WINDOW.ago
|
||||
|
||||
structural_skip_reason(pending_execution) || behavioral_skip_reason(pending_execution)
|
||||
end
|
||||
|
||||
def structural_skip_reason(pending_execution)
|
||||
rule = pending_execution.automation_rule
|
||||
return 'rule_inactive' if rule.nil? || !rule.active?
|
||||
return 'conversation_gone' if pending_execution.conversation.nil?
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def behavioral_skip_reason(pending_execution)
|
||||
return 'episode_moved' unless pending_execution.episode_current?
|
||||
return AutomationRulePendingExecution::CONDITIONS_CHANGED_SKIP unless conditions_still_match?(pending_execution)
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def conditions_still_match?(pending_execution)
|
||||
AutomationRules::ConditionsFilterService.new(
|
||||
pending_execution.automation_rule,
|
||||
pending_execution.conversation,
|
||||
{ message: pending_execution.message }
|
||||
).perform.present?
|
||||
end
|
||||
|
||||
# Marked before the actions run: a row that dies here stays `executing`, which no sweep reclaims,
|
||||
# so a message/email/webhook is never sent twice. Everything up to this point is still retryable.
|
||||
def execute(pending_execution)
|
||||
pending_execution.update!(status: :executing)
|
||||
AutomationRules::ActionService.new(
|
||||
pending_execution.automation_rule,
|
||||
pending_execution.account,
|
||||
pending_execution.conversation
|
||||
).perform
|
||||
pending_execution.update!(status: :executed)
|
||||
end
|
||||
|
||||
def delayed_automations_disabled?
|
||||
GlobalConfig.get('DISABLE_DELAYED_AUTOMATIONS')['DISABLE_DELAYED_AUTOMATIONS']
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class AutomationRules::ResumePausedExecutionsJob < ApplicationJob
|
||||
# Enqueued the moment the account flag flips back on, ahead of the next sweep, so overdue rows
|
||||
# are rescheduled before that sweep's per-row jobs could mark them expired.
|
||||
queue_as :medium
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(account)
|
||||
AutomationRulePendingExecution.reschedule_paused(account)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
class AutomationRules::TriggerPendingExecutionsJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
DEFAULT_SWEEP_LIMIT = 1000
|
||||
|
||||
def perform
|
||||
return if delayed_automations_disabled?
|
||||
|
||||
started_at = Time.current
|
||||
purged = AutomationRulePendingExecution.purge_terminal!
|
||||
|
||||
rows = AutomationRulePendingExecution.sweepable.for_enabled_accounts.order(:due_at).limit(sweep_limit).to_a
|
||||
rows.each { |row| AutomationRules::ProcessPendingExecutionJob.perform_later(row) }
|
||||
|
||||
log_summary(enqueued: rows.size, capped: rows.size >= sweep_limit, purged: purged,
|
||||
abandoned: AutomationRulePendingExecution.abandoned.count, started_at: started_at)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def delayed_automations_disabled?
|
||||
GlobalConfig.get('DISABLE_DELAYED_AUTOMATIONS')['DISABLE_DELAYED_AUTOMATIONS']
|
||||
end
|
||||
|
||||
def sweep_limit
|
||||
(InstallationConfig.find_by(name: 'AUTOMATION_PENDING_EXECUTIONS_SWEEP_LIMIT')&.value || DEFAULT_SWEEP_LIMIT).to_i
|
||||
end
|
||||
|
||||
def log_summary(enqueued:, capped:, purged:, abandoned:, started_at:)
|
||||
summary = { event: 'completed', enqueued: enqueued, capped: capped, purged: purged, abandoned: abandoned,
|
||||
duration_ms: ((Time.current - started_at) * 1000).round }
|
||||
Rails.logger.info("[AutomationRules::TriggerPendingExecutionsJob] #{summary.to_json}")
|
||||
end
|
||||
end
|
||||
@@ -19,6 +19,9 @@ class TriggerScheduledItemsJob < ApplicationJob
|
||||
|
||||
# Job to sync whatsapp templates
|
||||
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
|
||||
|
||||
# Job to trigger pending executions
|
||||
AutomationRules::TriggerPendingExecutionsJob.perform_later
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class AutomationRuleListener < BaseListener
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, message.conversation,
|
||||
{ message: message, changed_attributes: changed_attributes }).perform
|
||||
::AutomationRules::ActionService.new(rule, account, message.conversation).perform if conditions_match.present?
|
||||
execute_rule(rule, account, message.conversation, message: message) if conditions_match.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -46,13 +46,36 @@ class AutomationRuleListener < BaseListener
|
||||
account = conversation.account
|
||||
changed_attributes = event.data[:changed_attributes]
|
||||
|
||||
return unless rule_present?(event_name, account)
|
||||
|
||||
rules = current_account_rules(event_name, account)
|
||||
rules = conversation_rules(event_name, account)
|
||||
return if rules.blank?
|
||||
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
|
||||
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
|
||||
execute_rule(rule, account, conversation) if conditions_match.present?
|
||||
end
|
||||
end
|
||||
|
||||
# A delayed conversation rule reads as "the conversation has been in this status for N minutes",
|
||||
# so a conversation created in that status must arm it too. Creation never dispatches
|
||||
# CONVERSATION_UPDATED, and both paths key the episode on the same status_changed_at, so a later
|
||||
# update arming the same episode is deduped by the unique index.
|
||||
def conversation_rules(event_name, account)
|
||||
rules = current_account_rules(event_name, account)
|
||||
return rules unless event_name == 'conversation_created'
|
||||
|
||||
rules + current_account_rules('conversation_updated', account).where.not(execution_delay: nil)
|
||||
end
|
||||
|
||||
# Delayed rules record a pending execution instead of acting; the sweep re-checks and
|
||||
# runs them at due time. Flag off means no arming and no immediate fallback — a delayed
|
||||
# message silently becoming instant is worse than skipping.
|
||||
def execute_rule(rule, account, conversation, message: nil)
|
||||
if rule.execution_delay.present?
|
||||
return unless account.feature_enabled?('delayed_automations')
|
||||
|
||||
AutomationRulePendingExecution.schedule(rule: rule, conversation: conversation, message: message)
|
||||
else
|
||||
::AutomationRules::ActionService.new(rule, account, conversation).perform
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ class Account < ApplicationRecord
|
||||
has_many :articles, dependent: :destroy_async, class_name: '::Article'
|
||||
has_many :assignment_policies, dependent: :destroy_async
|
||||
has_many :automation_rules, dependent: :destroy_async
|
||||
has_many :automation_rule_pending_executions, dependent: :delete_all
|
||||
has_many :macros, dependent: :destroy_async
|
||||
has_many :campaigns, dependent: :destroy_async
|
||||
has_many :canned_responses, dependent: :destroy_async
|
||||
@@ -111,6 +112,7 @@ class Account < ApplicationRecord
|
||||
before_validation :validate_limit_keys
|
||||
after_create_commit :notify_creation
|
||||
after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts?
|
||||
after_update_commit :resume_delayed_automations, if: -> { saved_change_to_feature_delayed_automations? && feature_delayed_automations? }
|
||||
after_destroy :remove_account_sequences
|
||||
|
||||
def agents
|
||||
@@ -189,6 +191,10 @@ class Account < ApplicationRecord
|
||||
::Conversations::UnreadCounts::Store.clear_account!(id)
|
||||
end
|
||||
|
||||
def resume_delayed_automations
|
||||
AutomationRules::ResumePausedExecutionsJob.perform_later(self)
|
||||
end
|
||||
|
||||
trigger.after(:insert).for_each(:row) do
|
||||
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
|
||||
end
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
#
|
||||
# Table name: automation_rules
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# actions :jsonb not null
|
||||
# active :boolean default(TRUE), not null
|
||||
# conditions :jsonb not null
|
||||
# description :text
|
||||
# event_name :string not null
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# id :bigint not null, primary key
|
||||
# actions :jsonb not null
|
||||
# active :boolean default(TRUE), not null
|
||||
# conditions :jsonb not null
|
||||
# description :text
|
||||
# event_name :string not null
|
||||
# execution_delay :integer
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -21,7 +22,13 @@ class AutomationRule < ApplicationRecord
|
||||
include Rails.application.routes.url_helpers
|
||||
include Reauthorizable
|
||||
|
||||
EXECUTION_DELAY_RANGE = (10..43_200) # minutes: 10 min to 30 days
|
||||
# Conversation-level delayed rules key their episode on status; only status and attributes
|
||||
# that never change after the delay (inbox) are safe to also filter on.
|
||||
DELAYED_CONVERSATION_ATTRIBUTES = %w[status inbox_id].freeze
|
||||
|
||||
belongs_to :account
|
||||
has_many :pending_executions, class_name: 'AutomationRulePendingExecution', dependent: :delete_all
|
||||
has_many_attached :files
|
||||
|
||||
validate :json_conditions_format
|
||||
@@ -29,8 +36,13 @@ class AutomationRule < ApplicationRecord
|
||||
validate :query_operator_presence
|
||||
validate :query_operator_value
|
||||
validates :account_id, presence: true
|
||||
validates :execution_delay, numericality: { only_integer: true, in: EXECUTION_DELAY_RANGE }, allow_nil: true
|
||||
validate :execution_delay_supported_conditions
|
||||
validate :execution_delay_supported_event
|
||||
|
||||
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
|
||||
# Discard rows armed under the old definition; they re-arm on the next matching event.
|
||||
after_update :discard_stale_pending_executions, if: :execution_config_changed?
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
@@ -95,6 +107,35 @@ class AutomationRule < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# The fire-time re-check cannot reconstruct changed_attributes, so delayed rules
|
||||
# cannot use attribute_changed conditions.
|
||||
def execution_delay_supported_conditions
|
||||
return if execution_delay.blank? || conditions.blank?
|
||||
return if conditions.none? { |obj| obj['filter_operator'] == 'attribute_changed' }
|
||||
|
||||
errors.add(:execution_delay, 'cannot be used with attribute_changed conditions.')
|
||||
end
|
||||
|
||||
# Conversation-level episodes key on status_changed_at alone. Mutable attributes would collapse
|
||||
# distinct periods into one episode, so only status and immutable filters (inbox) are allowed.
|
||||
def execution_delay_supported_event
|
||||
return if execution_delay.blank? || conditions.blank? || event_name == 'message_created'
|
||||
return if conditions.all? { |obj| DELAYED_CONVERSATION_ATTRIBUTES.include?(obj['attribute_key']) }
|
||||
|
||||
errors.add(:execution_delay, 'only supports status and inbox conditions for conversation-level events.')
|
||||
end
|
||||
|
||||
def execution_config_changed?
|
||||
saved_change_to_execution_delay? || saved_change_to_event_name? ||
|
||||
saved_change_to_conditions? || saved_change_to_actions?
|
||||
end
|
||||
|
||||
def discard_stale_pending_executions
|
||||
# armed = pending + processing, the rows the sweep would otherwise still run. Rows already
|
||||
# executing are left alone: their actions are in flight and cannot be called back.
|
||||
pending_executions.armed.delete_all
|
||||
end
|
||||
|
||||
def validate_single_condition(condition)
|
||||
query_operator = condition['query_operator']
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: automation_rule_pending_executions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# due_at :datetime not null
|
||||
# episode_key :string not null
|
||||
# skip_reason :string
|
||||
# status :integer default("pending"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# automation_rule_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# message_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_automation_rule_pending_executions_on_account_id (account_id)
|
||||
# index_automation_rule_pending_executions_on_automation_rule_id (automation_rule_id)
|
||||
# index_automation_rule_pending_executions_on_conversation_id (conversation_id)
|
||||
# index_automation_rule_pending_executions_on_status_and_due_at (status,due_at)
|
||||
# uniq_automation_pending_execution_episode (automation_rule_id,conversation_id,episode_key) UNIQUE
|
||||
#
|
||||
class AutomationRulePendingExecution < ApplicationRecord
|
||||
# Rows older than this never fire (bounds backlog replay after downtime).
|
||||
DUE_WINDOW = 3.days
|
||||
# A processing row whose lock is older than this is treated as abandoned and reclaimed.
|
||||
STALE_PROCESSING_TIMEOUT = 15.minutes
|
||||
# Terminal rows are purged after this to keep the table bounded.
|
||||
RETENTION_WINDOW = 30.days
|
||||
# Skip reason for a row cancelled only because conditions no longer matched at fire time; unlike
|
||||
# other terminal reasons, a later qualifying message can re-arm it (see .schedule).
|
||||
CONDITIONS_CHANGED_SKIP = 'conditions_changed'.freeze
|
||||
|
||||
belongs_to :automation_rule
|
||||
belongs_to :conversation
|
||||
belongs_to :account
|
||||
belongs_to :message, optional: true
|
||||
|
||||
# `processing` is claimed but not yet acting, so it is safe to reclaim and retry. `executing`
|
||||
# means the actions are running: a row that dies there is never replayed, because the actions
|
||||
# are customer-facing (messages, emails, webhooks) and repeating them is worse than dropping them.
|
||||
enum status: { pending: 0, processing: 1, executed: 2, skipped: 3, executing: 4 }
|
||||
|
||||
# Rows a sweep should hand to a worker: due pending rows, plus processing rows whose lock went stale.
|
||||
scope :sweepable, lambda {
|
||||
pending.where(due_at: ..Time.current).or(processing.where(updated_at: ...STALE_PROCESSING_TIMEOUT.ago))
|
||||
}
|
||||
|
||||
# Rows whose worker died mid-action. Nothing reclaims them; the sweep only counts them so a
|
||||
# crash that strands customer-facing actions is visible instead of silent.
|
||||
scope :abandoned, -> { executing.where(updated_at: ...STALE_PROCESSING_TIMEOUT.ago) }
|
||||
|
||||
# Non-terminal rows still bound to fire (a stale processing row is reclaimed by the sweep).
|
||||
scope :armed, -> { where(status: [statuses[:pending], statuses[:processing]]) }
|
||||
|
||||
# Excludes rows whose account paused delayed automations, so one disabled account's backlog
|
||||
# can't fill the sweep limit and starve enabled accounts (paused rows resume on re-enable).
|
||||
scope :for_enabled_accounts, -> { joins(:account).merge(Account.feature_delayed_automations) }
|
||||
|
||||
def self.schedule(rule:, conversation:, message: nil)
|
||||
# status_changed_at is only written from this feature onwards, so a conversation that predates it
|
||||
# has no status clock. Anchoring on created_at would make every old conversation instantly
|
||||
# overdue and fire on the next sweep; leave them for their next status change to arm.
|
||||
return if message.nil? && conversation.status_changed_at.blank?
|
||||
|
||||
key = arm_episode_key_for(conversation, message)
|
||||
anchor = arm_anchor_for(conversation, message)
|
||||
create!(
|
||||
automation_rule: rule, conversation: conversation, account_id: conversation.account_id,
|
||||
message_id: message&.id, episode_key: key, due_at: rule.execution_delay.minutes.since(anchor)
|
||||
)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
rearm_or_advance_episode(rule, conversation, key, message, anchor)
|
||||
end
|
||||
|
||||
# The episode is already armed. Status episodes keep their first clock (a status change would
|
||||
# give a new key), so only message episodes advance or re-arm here.
|
||||
def self.rearm_or_advance_episode(rule, conversation, key, message, anchor)
|
||||
return unless message
|
||||
|
||||
due_at = rule.execution_delay.minutes.since(anchor)
|
||||
row = find_by!(automation_rule_id: rule.id, conversation_id: conversation.id, episode_key: key)
|
||||
# The lock (and the reload it does) makes the compare-and-write atomic. Two listeners racing on
|
||||
# the same episode would otherwise both read the old message_id and let whichever wrote last
|
||||
# win, so an older message could overwrite a newer one and pull due_at backwards.
|
||||
row.with_lock do
|
||||
# Jobs can arrive out of order; only a strictly newer message advances or re-arms, so a late
|
||||
# older message can't pull due_at backwards and fire before the delay elapses.
|
||||
next unless message.id > row.message_id
|
||||
|
||||
if row.condition_skipped?
|
||||
# A message episode key can recur (no new incoming reply) while conditions swing back into
|
||||
# match, so a later qualifying message re-arms the condition-only skip instead of dropping.
|
||||
row.update!(status: :pending, skip_reason: nil, due_at: due_at, message_id: message.id)
|
||||
elsif row.pending? || row.stale_processing?
|
||||
# Track the newest qualifying message. Reply-chase advances due_at with each agent reply;
|
||||
# awaiting-agent keeps its first clock (its anchor is the stable waiting_since, so due_at is
|
||||
# unchanged). Re-anchoring a row whose worker died mid-run back to pending also keeps a
|
||||
# stale reclaim from firing the old clock instead of the latest one.
|
||||
row.update!(status: :pending, due_at: due_at, message_id: message.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# The wait is measured from when the qualifying event happened, not when this (possibly
|
||||
# backlogged or retried) listener runs, so a late dispatch still fires on schedule. Mirrors
|
||||
# the timestamps the episode keys track.
|
||||
def self.arm_anchor_for(conversation, message)
|
||||
if message.nil?
|
||||
conversation.status_changed_at
|
||||
elsif message.incoming?
|
||||
conversation.waiting_since.presence || message.created_at
|
||||
else
|
||||
message.created_at
|
||||
end
|
||||
end
|
||||
|
||||
# waiting_since is written just after MESSAGE_CREATED dispatches, so it can still be nil when
|
||||
# an awaiting-agent episode arms. It becomes the starting message's created_at, so use that
|
||||
# here; the strict fire-time key (episode_key_for) then matches once waiting_since is settled.
|
||||
def self.arm_episode_key_for(conversation, message)
|
||||
return episode_key_for(conversation, message) unless message&.incoming? && conversation.waiting_since.blank?
|
||||
|
||||
"awaiting_agent:#{microsecond_stamp(message.created_at)}"
|
||||
end
|
||||
|
||||
# Microsecond integer, not a float: epoch seconds carry ~16 significant digits, past float64's
|
||||
# precision, so an in-memory timestamp (arm time) and its DB-reloaded value (fire time) would
|
||||
# round to different floats. strftime is exact on both. Sub-second distinguishes rapid episodes.
|
||||
def self.microsecond_stamp(time)
|
||||
time&.strftime('%s%6N') || '0'
|
||||
end
|
||||
|
||||
# Episode keys identify one qualifying stretch of conversation state; when the recomputed
|
||||
# key no longer matches, the episode ended and the pending action is cancelled at fire time.
|
||||
def self.episode_key_for(conversation, message)
|
||||
if message.nil?
|
||||
# Sub-second precision so a resolve→reopen inside one second still ends the episode.
|
||||
# Integer microseconds (not a float) so an in-memory arm and a DB-reloaded fire agree.
|
||||
"status:#{microsecond_stamp(conversation.status_changed_at)}"
|
||||
elsif message.incoming?
|
||||
# waiting_since is cleared on agent/bot reply, so a reply invalidates this episode. Strict
|
||||
# here: at fire time a nil waiting_since means the agent replied (episode ended).
|
||||
"awaiting_agent:#{microsecond_stamp(conversation.waiting_since)}"
|
||||
else
|
||||
# A new customer message changes the max incoming id, invalidating this episode.
|
||||
"reply_chase:#{conversation.messages.incoming.maximum(:id) || 0}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.purge_terminal!
|
||||
where(status: [statuses[:executed], statuses[:skipped]], updated_at: ...RETENTION_WINDOW.ago)
|
||||
.in_batches(of: 1000).delete_all
|
||||
end
|
||||
|
||||
# Rows that came due while an account had delayed automations paused would expire the moment
|
||||
# the sweep reaches them on resume. Reset their clock so pause/resume replays them (still
|
||||
# subject to the fire-time episode/condition re-checks) instead of silently dropping them.
|
||||
def self.reschedule_paused(account)
|
||||
overdue = pending.where(account_id: account.id, due_at: ...DUE_WINDOW.ago)
|
||||
overdue.find_each { |row| row.update!(due_at: Time.current) }
|
||||
end
|
||||
|
||||
# Atomic claim: only one worker can move a row into processing, so a row re-enqueued by an
|
||||
# overlapping sweep (or after a stale reclaim) cannot double-execute. Refreshing updated_at
|
||||
# renews the lock, keeping the row out of the stale window while this worker holds it.
|
||||
def claim!
|
||||
with_lock do
|
||||
next false unless claimable?
|
||||
|
||||
update!(status: :processing, updated_at: Time.current)
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def episode_current?
|
||||
self.class.episode_key_for(conversation, message) == episode_key
|
||||
end
|
||||
|
||||
# The claim renews updated_at, so a processing row past the timeout means its worker died. Only
|
||||
# then may a re-arm take the row back: pulling a live worker's row to pending would let the sweep
|
||||
# claim it and run the same actions alongside the worker still executing them.
|
||||
def stale_processing?
|
||||
processing? && updated_at < STALE_PROCESSING_TIMEOUT.ago
|
||||
end
|
||||
|
||||
def condition_skipped?
|
||||
skipped? && skip_reason == CONDITIONS_CHANGED_SKIP
|
||||
end
|
||||
|
||||
def terminal?
|
||||
executed? || skipped?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def claimable?
|
||||
# due_at guard: a reply-chase reschedule can push due_at forward after this row was enqueued;
|
||||
# such a row must wait for a later sweep instead of firing early.
|
||||
(pending? && due_at <= Time.current) || stale_processing?
|
||||
end
|
||||
end
|
||||
@@ -15,6 +15,7 @@
|
||||
# priority :integer
|
||||
# snoozed_until :datetime
|
||||
# status :integer default("open"), not null
|
||||
# status_changed_at :datetime
|
||||
# uuid :uuid not null
|
||||
# waiting_since :datetime
|
||||
# created_at :datetime not null
|
||||
@@ -124,8 +125,10 @@ class Conversation < ApplicationRecord
|
||||
has_many :notifications, as: :primary_actor, dependent: :destroy_async
|
||||
has_many :attachments, through: :messages
|
||||
has_many :reporting_events, dependent: :destroy_async
|
||||
has_many :automation_rule_pending_executions, dependent: :delete_all
|
||||
|
||||
before_save :ensure_snooze_until_reset
|
||||
before_save :set_status_changed_at
|
||||
before_create :determine_conversation_status
|
||||
before_create :ensure_waiting_since
|
||||
|
||||
@@ -272,6 +275,10 @@ class Conversation < ApplicationRecord
|
||||
self.snoozed_until = nil unless snoozed?
|
||||
end
|
||||
|
||||
def set_status_changed_at
|
||||
self.status_changed_at = Time.current if new_record? || status_changed?
|
||||
end
|
||||
|
||||
def ensure_waiting_since
|
||||
self.waiting_since = created_at
|
||||
end
|
||||
|
||||
@@ -7,4 +7,5 @@ json.conditions automation_rule.conditions
|
||||
json.actions automation_rule.actions
|
||||
json.created_on automation_rule.created_at.to_i
|
||||
json.active automation_rule.active?
|
||||
json.execution_delay automation_rule.execution_delay
|
||||
json.files automation_rule.file_base_data if automation_rule.files.any?
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.16.1'
|
||||
version: '4.16.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -268,3 +268,8 @@
|
||||
display_name: WhatsApp Embedded Signup Flow
|
||||
enabled: false
|
||||
column: feature_flags_ext_1
|
||||
- name: delayed_automations
|
||||
display_name: Delayed Automations
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
column: feature_flags_ext_1
|
||||
|
||||
@@ -567,3 +567,10 @@
|
||||
value: 'https://us.cloud.langfuse.com'
|
||||
locked: false
|
||||
## ---- End of LLM Observability ---- ##
|
||||
|
||||
- name: DISABLE_DELAYED_AUTOMATIONS
|
||||
display_title: 'Disable delayed automations'
|
||||
description: 'Emergency stop for delayed automation rules: halts the pending-execution sweep and per-row execution within one tick'
|
||||
value: false
|
||||
locked: false
|
||||
type: boolean
|
||||
|
||||
@@ -143,20 +143,6 @@ features:
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-5.2
|
||||
conversation_faq_matching:
|
||||
models:
|
||||
[
|
||||
gpt-4.1-mini,
|
||||
gpt-5-mini,
|
||||
gpt-4.1,
|
||||
gpt-5.1,
|
||||
gpt-5.2,
|
||||
claude-haiku-4.5,
|
||||
claude-sonnet-4.5,
|
||||
gemini-3-flash,
|
||||
gemini-3-pro,
|
||||
]
|
||||
default: gpt-4.1-mini
|
||||
pdf_faq_generation:
|
||||
models: [gpt-4.1-mini, gpt-5-mini, gpt-4.1, gpt-5.1, gpt-5.2]
|
||||
default: gpt-4.1-mini
|
||||
|
||||
@@ -626,7 +626,6 @@ en:
|
||||
label_suggestion: 'Label suggestion'
|
||||
document_faq_generation: 'Document FAQ generation'
|
||||
conversation_faq_generation: 'Conversation FAQ generation'
|
||||
conversation_faq_matching: 'Conversation FAQ matching'
|
||||
help_center_article_generation: 'Help center article generation'
|
||||
onboarding_content_generation: 'Onboarding content generation'
|
||||
help_center_query_translation: 'Help center query translation'
|
||||
|
||||
+1
-6
@@ -66,8 +66,7 @@ Rails.application.routes.draw do
|
||||
resources :assistants do
|
||||
member do
|
||||
post :playground
|
||||
get :metrics
|
||||
get :faq_stats
|
||||
get :stats
|
||||
get :summary
|
||||
get :drilldown
|
||||
end
|
||||
@@ -79,10 +78,6 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resources :agent_sessions, only: [:show]
|
||||
resources :assistant_responses
|
||||
resources :faq_suggestions, only: [:index, :show, :update] do
|
||||
post :approve, on: :member
|
||||
post :dismiss, on: :member
|
||||
end
|
||||
resources :message_reports, only: [:create]
|
||||
resources :bulk_actions, only: [:create]
|
||||
resources :copilot_threads, only: [:index, :create] do
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddExecutionDelayToAutomationRules < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :automation_rules, :execution_delay, :integer
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddStatusChangedAtToConversations < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :conversations, :status_changed_at, :datetime
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class CreateAutomationRulePendingExecutions < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :automation_rule_pending_executions do |t|
|
||||
t.references :automation_rule, null: false
|
||||
t.references :conversation, null: false
|
||||
t.references :account, null: false
|
||||
t.bigint :message_id
|
||||
t.datetime :due_at, null: false
|
||||
t.string :episode_key, null: false
|
||||
t.integer :status, null: false, default: 0
|
||||
t.string :skip_reason
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :automation_rule_pending_executions, [:status, :due_at]
|
||||
add_index :automation_rule_pending_executions,
|
||||
[:automation_rule_id, :conversation_id, :episode_key],
|
||||
unique: true, name: 'uniq_automation_pending_execution_episode'
|
||||
end
|
||||
end
|
||||
@@ -1,7 +0,0 @@
|
||||
class PurgePendingCaptainAssistantResponses < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
execute('DELETE FROM captain_assistant_responses WHERE status = 0')
|
||||
end
|
||||
|
||||
def down; end
|
||||
end
|
||||
+21
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_14_123000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -277,6 +277,24 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_14_123000) do
|
||||
t.index ["user_id", "user_type"], name: "user_index"
|
||||
end
|
||||
|
||||
create_table "automation_rule_pending_executions", force: :cascade do |t|
|
||||
t.bigint "automation_rule_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "message_id"
|
||||
t.datetime "due_at", null: false
|
||||
t.string "episode_key", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.string "skip_reason"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_automation_rule_pending_executions_on_account_id"
|
||||
t.index ["automation_rule_id", "conversation_id", "episode_key"], name: "uniq_automation_pending_execution_episode", unique: true
|
||||
t.index ["automation_rule_id"], name: "index_automation_rule_pending_executions_on_automation_rule_id"
|
||||
t.index ["conversation_id"], name: "index_automation_rule_pending_executions_on_conversation_id"
|
||||
t.index ["status", "due_at"], name: "index_automation_rule_pending_executions_on_status_and_due_at"
|
||||
end
|
||||
|
||||
create_table "automation_rules", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", null: false
|
||||
@@ -287,6 +305,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_14_123000) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "active", default: true, null: false
|
||||
t.integer "execution_delay"
|
||||
t.index ["account_id"], name: "index_automation_rules_on_account_id"
|
||||
end
|
||||
|
||||
@@ -788,6 +807,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_14_123000) do
|
||||
t.datetime "waiting_since"
|
||||
t.text "cached_label_list"
|
||||
t.bigint "assignee_agent_bot_id"
|
||||
t.datetime "status_changed_at"
|
||||
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
|
||||
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
|
||||
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
|
||||
|
||||
@@ -23,11 +23,10 @@ class Captain::AssistantStatsBuilder
|
||||
# ('this_month', 'last_month'). `timezone_offset` is the viewer's UTC offset in
|
||||
# hours (as the reports API sends it), so month/day boundaries anchor to the
|
||||
# viewer's day rather than UTC. Both windows are resolved by AssistantStatsWindow.
|
||||
def initialize(assistant, range = Captain::AssistantStatsWindow::DEFAULT_RANGE, timezone_offset = nil, suggestions_scope: nil)
|
||||
def initialize(assistant, range = Captain::AssistantStatsWindow::DEFAULT_RANGE, timezone_offset = nil)
|
||||
@assistant = assistant
|
||||
@account = assistant.account
|
||||
@window = Captain::AssistantStatsWindow.new(range, timezone_offset)
|
||||
@suggestions_scope = suggestions_scope || assistant.faq_suggestions
|
||||
end
|
||||
|
||||
def metrics
|
||||
@@ -38,26 +37,9 @@ class Captain::AssistantStatsBuilder
|
||||
build_metrics(current, previous)
|
||||
end
|
||||
|
||||
# Approved FAQ, open suggestion, and document counts in a single round trip.
|
||||
def faq_stats
|
||||
approved, suggestions, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
|
||||
Arel.sql("(#{open_suggestion_count_sql})"),
|
||||
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
|
||||
)
|
||||
total = approved + suggestions
|
||||
|
||||
{
|
||||
approved: approved,
|
||||
suggestions: suggestions,
|
||||
documents: documents,
|
||||
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :window, :suggestions_scope
|
||||
attr_reader :window
|
||||
|
||||
def current_range
|
||||
window.current
|
||||
@@ -74,7 +56,8 @@ class Captain::AssistantStatsBuilder
|
||||
handoff_rate: pack(current[:handoff], previous[:handoff], :point),
|
||||
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
|
||||
reopen_rate: pack(current[:reopen], previous[:reopen], :point),
|
||||
conversation_depth: pack(current[:depth], previous[:depth], :absolute)
|
||||
conversation_depth: pack(current[:depth], previous[:depth], :absolute),
|
||||
knowledge: knowledge
|
||||
}
|
||||
end
|
||||
|
||||
@@ -90,7 +73,7 @@ class Captain::AssistantStatsBuilder
|
||||
auto_resolution: rate(resolution[:resolved], handled),
|
||||
handoff: rate(resolution[:handoff], handled),
|
||||
hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
|
||||
reopen: reopen_rate(range, resolution[:resolved]),
|
||||
reopen: reopen_rate(range),
|
||||
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
|
||||
}
|
||||
end
|
||||
@@ -175,9 +158,7 @@ class Captain::AssistantStatsBuilder
|
||||
# derived from the assistant's handled conversations (not current inbox membership) so a later
|
||||
# inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
|
||||
# and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
|
||||
def reopen_rate(range, resolved_count)
|
||||
return 0 if resolved_count.zero?
|
||||
|
||||
def reopen_rate(range)
|
||||
resolved_scope = account.reporting_events
|
||||
.where(name: RESOLVED_EVENT_NAMES, created_at: range,
|
||||
conversation_id: handled_scope(range).select(:conversation_id))
|
||||
@@ -197,11 +178,24 @@ class Captain::AssistantStatsBuilder
|
||||
'ON resolves.conversation_id = reporting_events.conversation_id ' \
|
||||
'AND reporting_events.event_end_time >= resolves.event_end_time')
|
||||
.distinct.count('reporting_events.conversation_id')
|
||||
rate(reopened, resolved_count)
|
||||
rate(reopened, resolved_scope.distinct.count(:conversation_id))
|
||||
end
|
||||
|
||||
def open_suggestion_count_sql
|
||||
suggestions_scope.where(assistant_id: assistant.id).open.reorder(nil).select('COUNT(*)').to_sql
|
||||
# Approved/pending FAQ counts and the document total in a single round trip.
|
||||
def knowledge
|
||||
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
|
||||
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
|
||||
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
|
||||
)
|
||||
total = approved + pending
|
||||
|
||||
{
|
||||
approved: approved,
|
||||
pending: pending,
|
||||
documents: documents,
|
||||
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
|
||||
}
|
||||
end
|
||||
|
||||
def rate(numerator, denominator)
|
||||
|
||||
+5
-2
@@ -43,6 +43,8 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
|
||||
)
|
||||
end
|
||||
|
||||
base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
|
||||
|
||||
if permitted_params[:search].present?
|
||||
search_term = "%#{permitted_params[:search]}%"
|
||||
base_query = base_query.where(
|
||||
@@ -71,14 +73,15 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id, :assistant_id, :page, :document_id, :account_id, :search)
|
||||
params.permit(:id, :assistant_id, :page, :document_id, :account_id, :status, :search)
|
||||
end
|
||||
|
||||
def response_params
|
||||
params.require(:assistant_response).permit(
|
||||
:question,
|
||||
:answer,
|
||||
:assistant_id
|
||||
:assistant_id,
|
||||
:status
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown]
|
||||
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
|
||||
|
||||
def index
|
||||
@assistants = account_assistants.ordered
|
||||
@@ -42,19 +42,10 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
@tools = assistant.available_agent_tools
|
||||
end
|
||||
|
||||
def metrics
|
||||
def stats
|
||||
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
|
||||
end
|
||||
|
||||
def faq_stats
|
||||
builder = Captain::AssistantStatsBuilder.new(
|
||||
@assistant,
|
||||
suggestions_scope: Captain::FaqSuggestionFinder.new(Current.user, Current.account).perform
|
||||
)
|
||||
|
||||
render json: builder.faq_stats
|
||||
end
|
||||
|
||||
def summary
|
||||
window = Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
|
||||
result = cached_or_generated_summary(window, summary_stats)
|
||||
|
||||
@@ -39,10 +39,14 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
||||
responses = Current.account.captain_assistant_responses.where(id: params[:ids])
|
||||
return unless responses.exists?
|
||||
|
||||
return render json: { success: false }, status: :unprocessable_content unless params[:fields][:status] == 'delete'
|
||||
|
||||
responses.destroy_all
|
||||
[]
|
||||
case params[:fields][:status]
|
||||
when 'approve'
|
||||
responses.pending.update(status: 'approved')
|
||||
responses
|
||||
when 'delete'
|
||||
responses.destroy_all
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def handle_documents
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
class Api::V1::Accounts::Captain::FaqSuggestionsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action -> { check_authorization(Captain::FaqSuggestion) }
|
||||
before_action :set_accessible_suggestions
|
||||
before_action :set_suggestion, except: [:index]
|
||||
|
||||
RESULTS_PER_PAGE = 25
|
||||
SOURCE_PREVIEW_LIMIT = 50
|
||||
|
||||
def index
|
||||
@current_page = permitted_params[:page] || 1
|
||||
filtered_query = apply_filters(@suggestions)
|
||||
@suggestions_count = filtered_query.count
|
||||
@suggestions = filtered_query.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def show
|
||||
@observations = @suggestion.observations
|
||||
.where(conversation_id: accessible_conversations.select(:id))
|
||||
.includes(:conversation)
|
||||
.order(created_at: :desc)
|
||||
.limit(SOURCE_PREVIEW_LIMIT)
|
||||
end
|
||||
|
||||
def update
|
||||
@suggestion.with_lock do
|
||||
raise ActiveRecord::RecordNotFound unless @suggestion.open?
|
||||
|
||||
@suggestion.update!(suggestion_params)
|
||||
end
|
||||
end
|
||||
|
||||
def approve
|
||||
attributes = params[:faq_suggestion].present? ? suggestion_params : {}
|
||||
@response = Captain::FaqSuggestionApprovalService.new(@suggestion, attributes).perform
|
||||
end
|
||||
|
||||
def dismiss
|
||||
@suggestion.with_lock do
|
||||
raise ActiveRecord::RecordNotFound unless @suggestion.open?
|
||||
|
||||
@suggestion.dismissed!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_filters(base_query)
|
||||
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
|
||||
base_query = base_query.where(status: permitted_params[:status]) if permitted_params[:status].present?
|
||||
|
||||
if permitted_params[:search].present?
|
||||
# TODO: Move FAQ suggestion search to Elasticsearch when the records are indexed there.
|
||||
search_term = "%#{permitted_params[:search]}%"
|
||||
base_query = base_query.where('question ILIKE :search OR answer ILIKE :search', search: search_term)
|
||||
end
|
||||
|
||||
base_query
|
||||
end
|
||||
|
||||
def set_accessible_suggestions
|
||||
@suggestions = Captain::FaqSuggestionFinder.new(Current.user, Current.account).perform.includes(:assistant).ordered
|
||||
end
|
||||
|
||||
def set_suggestion
|
||||
@suggestion = @suggestions.find(permitted_params[:id])
|
||||
end
|
||||
|
||||
def accessible_conversations
|
||||
Conversations::PermissionFilterService.new(Current.account.conversations, Current.user, Current.account).perform
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id, :assistant_id, :page, :status, :search)
|
||||
end
|
||||
|
||||
def suggestion_params
|
||||
params.require(:faq_suggestion).permit(:question, :answer)
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,6 @@
|
||||
module Enterprise::Api::V1::Accounts::AgentsController
|
||||
def create
|
||||
super
|
||||
return if @agent.blank?
|
||||
|
||||
associate_agent_with_custom_role
|
||||
end
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
class Captain::FaqSuggestionFinder
|
||||
def initialize(current_user, current_account)
|
||||
@current_user = current_user
|
||||
@current_account = current_account
|
||||
end
|
||||
|
||||
def perform
|
||||
suggestions = @current_account.captain_faq_suggestions
|
||||
return suggestions if account_user&.administrator?
|
||||
|
||||
accessible_suggestion_ids = Captain::FaqObservation
|
||||
.where(conversation_id: accessible_conversations.select(:id))
|
||||
.select(:faq_suggestion_id)
|
||||
suggestions.where(id: accessible_suggestion_ids)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def accessible_conversations
|
||||
Conversations::PermissionFilterService.new(@current_account.conversations, @current_user, @current_account).perform
|
||||
end
|
||||
|
||||
def account_user
|
||||
@account_user ||= @current_account.account_users.find_by(user_id: @current_user.id)
|
||||
end
|
||||
end
|
||||
@@ -1,30 +0,0 @@
|
||||
class Captain::Llm::ConversationFaqJob < MutexApplicationJob
|
||||
queue_as :low
|
||||
|
||||
LOCK_TIMEOUT = 10.minutes
|
||||
|
||||
retry_on_lock_conflict wait: 30.seconds, attempts: 30
|
||||
|
||||
def perform(conversation, assistant)
|
||||
inbox = conversation.inbox
|
||||
|
||||
return unless conversation.resolved?
|
||||
return unless inbox.captain_active?
|
||||
|
||||
return if assistant.config['feature_faq'].blank?
|
||||
|
||||
with_lock(lock_key(assistant, conversation), LOCK_TIMEOUT) do
|
||||
Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_suggestions
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def lock_key(assistant, conversation)
|
||||
format(
|
||||
::Redis::Alfred::CAPTAIN_CONVERSATION_FAQ_MUTEX,
|
||||
assistant_id: assistant.id,
|
||||
language: Captain::Llm::ConversationFaqService.language_for(conversation)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -8,6 +8,6 @@ class CaptainListener < BaseListener
|
||||
return unless conversation.inbox.captain_active?
|
||||
|
||||
Captain::Llm::ContactNotesService.new(assistant, conversation).generate_and_update_notes if assistant.config['feature_memory'].present?
|
||||
Captain::Llm::ConversationFaqJob.perform_later(conversation, assistant) if assistant.config['feature_faq'].present?
|
||||
Captain::Llm::ConversationFaqService.new(assistant, conversation).generate_and_deduplicate if assistant.config['feature_faq'].present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -44,7 +44,7 @@ class Captain::AssistantResponse < ApplicationRecord
|
||||
scope :by_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
|
||||
scope :with_document, ->(document_id) { where(document_id: document_id) }
|
||||
|
||||
enum status: { approved: 1 }
|
||||
enum status: { pending: 0, approved: 1 }
|
||||
|
||||
def self.search(query, account_id: nil)
|
||||
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(query)
|
||||
|
||||
@@ -7,11 +7,7 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def metrics?
|
||||
true
|
||||
end
|
||||
|
||||
def faq_stats?
|
||||
def stats?
|
||||
true
|
||||
end
|
||||
|
||||
@@ -35,14 +31,6 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def approve?
|
||||
update?
|
||||
end
|
||||
|
||||
def dismiss?
|
||||
update?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
class Captain::FaqSuggestionPolicy < ApplicationPolicy
|
||||
def index?
|
||||
true
|
||||
end
|
||||
|
||||
def show?
|
||||
true
|
||||
end
|
||||
|
||||
def update?
|
||||
true
|
||||
end
|
||||
|
||||
def approve?
|
||||
true
|
||||
end
|
||||
|
||||
def dismiss?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -1,26 +0,0 @@
|
||||
class Captain::FaqSuggestionApprovalService
|
||||
def initialize(suggestion, attributes = {})
|
||||
@suggestion = suggestion
|
||||
@attributes = attributes
|
||||
end
|
||||
|
||||
def perform
|
||||
suggestion.with_lock do
|
||||
raise ActiveRecord::RecordNotFound unless suggestion.open?
|
||||
|
||||
suggestion.update!(attributes) if attributes.present?
|
||||
|
||||
response = suggestion.assistant.responses.create!(
|
||||
question: suggestion.question,
|
||||
answer: suggestion.answer,
|
||||
status: :approved
|
||||
)
|
||||
suggestion.approved!
|
||||
response
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :suggestion, :attributes
|
||||
end
|
||||
@@ -1,66 +0,0 @@
|
||||
class Captain::Llm::ConversationFaqContentService
|
||||
def initialize(assistant, conversation)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
end
|
||||
|
||||
def generate
|
||||
[
|
||||
'Business Context:',
|
||||
JSON.pretty_generate(business_context),
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
conversation_messages
|
||||
].join("\n")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :assistant, :conversation
|
||||
|
||||
def conversation_messages
|
||||
messages = conversation
|
||||
.messages
|
||||
.where(message_type: %i[incoming outgoing], private: false)
|
||||
.order(created_at: :asc)
|
||||
|
||||
return "No messages in this conversation\n" if messages.empty?
|
||||
|
||||
messages.filter_map { |message| format_message(message) }.join
|
||||
end
|
||||
|
||||
def format_message(message)
|
||||
return unless source_message?(message)
|
||||
|
||||
message_content = message.content_for_llm
|
||||
return if message_content.blank?
|
||||
|
||||
sender = human_support_reply?(message) ? 'Support Agent' : 'User'
|
||||
"#{sender}: #{message_content}\n"
|
||||
end
|
||||
|
||||
def source_message?(message)
|
||||
return true if message.incoming? && message.sender_type == 'Contact'
|
||||
|
||||
human_support_reply?(message)
|
||||
end
|
||||
|
||||
def human_support_reply?(message)
|
||||
return false unless message.outgoing?
|
||||
return false if message.content_attributes['automation_rule_id'].present?
|
||||
return false if message.additional_attributes['campaign_id'].present?
|
||||
|
||||
message.sender_type == 'User' || message.content_attributes['external_echo'].present?
|
||||
end
|
||||
|
||||
def business_context
|
||||
{
|
||||
product_name: assistant.config['product_name'],
|
||||
assistant_description: assistant.description,
|
||||
instructions: assistant.config['instructions'],
|
||||
response_guidelines: assistant.response_guidelines,
|
||||
guardrails: assistant.guardrails
|
||||
}.compact
|
||||
end
|
||||
end
|
||||
@@ -1,75 +0,0 @@
|
||||
class Captain::Llm::ConversationFaqPromptsService
|
||||
class << self
|
||||
def generator(language = 'english')
|
||||
<<~PROMPT
|
||||
You create high-quality FAQ candidates from resolved support conversations.
|
||||
Only generate an FAQ when the conversation contains durable, reusable knowledge that would help many future customers.
|
||||
|
||||
## Source rules
|
||||
- The input starts with trusted business context. Use it to reject conversations about other businesses or topics, but never use it as the source of an FAQ answer.
|
||||
- The conversation history contains only customer messages and human support agent messages.
|
||||
- Base every FAQ strictly on information stated in the human support agent messages. Do not infer, generalize, or add external knowledge.
|
||||
- A human support agent must state every fact used in the FAQ answer. Customer messages cannot supply missing answer facts.
|
||||
- The human support agent must provide the final answer. If the agent only greets, asks clarifying questions, asks for contact details, promises to check, shares an attachment, or transfers the conversation, return: `{"faqs":[]}`.
|
||||
- For each FAQ, identify the human support agent message or messages that together provide a complete public answer to the same question. Combine facts only across related agent messages; never combine separate questions or unrelated topics. If those messages do not provide a complete public answer, remove that FAQ.
|
||||
|
||||
## Decision gate
|
||||
Return `{"faqs":[]}` unless every generated FAQ can pass all of these checks:
|
||||
1. The answer is fully stated by a human support agent, not by the customer.
|
||||
2. The answer is a public, durable rule or procedure, not a private account action, manual review, troubleshooting session, quote, file, link, or follow-up.
|
||||
3. The answer can be written without private identifiers, customer-specific facts, direct URLs, attachments, invoices, screenshots, or support-ticket steps.
|
||||
4. The question would still make sense in a help center if the original conversation, customer, and agent did not exist.
|
||||
Do not rescue a rejected conversation by rewriting it as a generic support question.
|
||||
|
||||
## Return no FAQ for
|
||||
- Spam, scams, advertisements, SEO/link-building pitches, adult/gambling/financial promotions, gibberish, abusive content, or conversations unrelated to the business being supported.
|
||||
- Account-specific, order-specific, payment-specific, subscription-specific, login/access, verification, delivery, certificate, or troubleshooting issues, even if they could be rewritten as a general support question.
|
||||
- Conversations that mainly hand off to a human, ask the customer to wait, request private identifiers or contact details, collect screenshots, attachments, or documents, or tell the customer to contact support for case review.
|
||||
- Temporary workarounds, one-off exceptions, unclear answers, unresolved problems, wrong-service conversations, complaints, greetings, or abandoned conversations.
|
||||
- Internal support workflow details, chat session rules, escalation mechanics, ticket-routing instructions, or "someone will get back to you" messages.
|
||||
- Answers that are just a direct/private link, attachment, file, invoice, one-off quote or estimate, account-specific URL, or instructions to open a support ticket.
|
||||
- Questions whose useful answer is "contact support", "wait for the team", "share your details", "we will check", or "this needs manual review".
|
||||
- Questions about whether support can help with a private issue, third-party service, transaction, payment, delivery, or account problem.
|
||||
- Pricing, policy, availability, roadmap, deadline, or legal claims unless the human support agent gives a clear and stable answer in the conversation.
|
||||
- Questions already answered only by asking the customer for more information.
|
||||
|
||||
## FAQ quality rules
|
||||
- Prefer returning no FAQ over a weak or narrow FAQ.
|
||||
- A good candidate teaches a generally reusable product, service, policy, setup, or process rule that another customer could use without contacting support.
|
||||
- Generate at most one FAQ unless the human agent clearly answered multiple distinct, reusable questions.
|
||||
- Do not create duplicate or overlapping FAQs in the same response.
|
||||
- Questions must be general enough for a help center, not personalized to the current customer.
|
||||
- Remove customer names, order numbers, invoice numbers, IDs, private URLs, phone numbers, emails, screenshots, attachments, and other personal or transaction-specific details.
|
||||
- Answers must be complete, self-contained, and supported by the human agent's messages.
|
||||
|
||||
## Examples
|
||||
- Customer mentions a price or procedure, then the human agent only greets or says they will check: return `{"faqs":[]}`.
|
||||
- Human agent shares only a private link, file, invoice, quote, screenshot, or attachment: return `{"faqs":[]}`.
|
||||
- Human agent clearly states a public rule, such as which purchases are allowed for a program or service: generate one general FAQ.
|
||||
|
||||
Generate the FAQs only in the #{language}, use no other language.
|
||||
If no suitable reusable FAQ is available, return: `{"faqs":[]}`.
|
||||
|
||||
Return only valid JSON in this exact structure:
|
||||
```json
|
||||
{ "faqs": [ { "question": "", "answer": "" } ] }
|
||||
```
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def same_faq
|
||||
<<~PROMPT
|
||||
Decide whether the new FAQ and existing FAQ are the same.
|
||||
|
||||
Return `same_faq` as true only when both questions ask the same thing and both answers give the same guidance.
|
||||
Wording, grammar, level of detail, and examples may differ. Return false when either FAQ adds, removes, contradicts, or changes a condition,
|
||||
policy, procedure, audience, product, plan, time frame, or outcome. Related FAQs are not the same FAQ. When uncertain, return false.
|
||||
|
||||
Return only valid JSON in this exact structure:
|
||||
```json
|
||||
{ "same_faq": true }
|
||||
```
|
||||
PROMPT
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,208 +1,167 @@
|
||||
class Captain::Llm::ConversationFaqService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
class SuggestionChangedError < StandardError; end
|
||||
|
||||
DISTANCE_THRESHOLD = 0.3
|
||||
MATCH_LIMIT = 5
|
||||
LLM_FEATURE = 'conversation_faq_generation'.freeze
|
||||
|
||||
def self.language_for(conversation)
|
||||
language = conversation.language.presence || conversation.account.locale.presence || I18n.default_locale.to_s
|
||||
normalize_language(language)
|
||||
end
|
||||
|
||||
def self.normalize_language(language)
|
||||
language.to_s.tr('-', '_').split('_').first.downcase
|
||||
end
|
||||
|
||||
private_class_method :normalize_language
|
||||
|
||||
def initialize(assistant, conversation)
|
||||
super(feature: LLM_FEATURE, account: conversation.account, fallback_model: Llm::Models.default_model_for(LLM_FEATURE))
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@content = Captain::Llm::ConversationFaqContentService.new(assistant, conversation).generate
|
||||
@embedding_service = Captain::Llm::EmbeddingService.new(account_id: conversation.account_id)
|
||||
@content = conversation_faq_content
|
||||
end
|
||||
|
||||
def generate_suggestions
|
||||
# Generates and deduplicates FAQs from conversation content
|
||||
# Skips processing if there was no human interaction
|
||||
def generate_and_deduplicate
|
||||
return [] if no_human_interaction?
|
||||
|
||||
generate.map { |faq| route_candidate(faq) }
|
||||
new_faqs = generate
|
||||
return [] if new_faqs.empty?
|
||||
|
||||
duplicate_faqs, unique_faqs = find_and_separate_duplicates(new_faqs)
|
||||
save_new_faqs(unique_faqs)
|
||||
log_duplicate_faqs(duplicate_faqs) if Rails.env.development?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :content, :conversation, :assistant, :embedding_service
|
||||
attr_reader :content, :conversation, :assistant
|
||||
|
||||
def conversation_faq_content
|
||||
[
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
conversation_faq_messages
|
||||
].join("\n")
|
||||
end
|
||||
|
||||
def conversation_faq_messages
|
||||
messages = conversation
|
||||
.messages
|
||||
.where(message_type: %i[incoming outgoing], private: false)
|
||||
.order(created_at: :asc)
|
||||
|
||||
return "No messages in this conversation\n" if messages.empty?
|
||||
|
||||
messages.filter_map { |message| format_conversation_faq_message(message) }.join
|
||||
end
|
||||
|
||||
def format_conversation_faq_message(message)
|
||||
return unless faq_source_message?(message)
|
||||
|
||||
content = message.content_for_llm
|
||||
return if content.blank?
|
||||
|
||||
sender = human_support_reply?(message) ? 'Support Agent' : 'User'
|
||||
"#{sender}: #{content}\n"
|
||||
end
|
||||
|
||||
def faq_source_message?(message)
|
||||
return true if message.incoming? && message.sender_type == 'Contact'
|
||||
|
||||
human_support_reply?(message)
|
||||
end
|
||||
|
||||
def human_support_reply?(message)
|
||||
return false unless message.outgoing?
|
||||
return false if message.content_attributes['automation_rule_id'].present?
|
||||
return false if message.additional_attributes['campaign_id'].present?
|
||||
|
||||
message.sender_type == 'User' || message.content_attributes['external_echo'].present?
|
||||
end
|
||||
|
||||
def no_human_interaction?
|
||||
conversation.first_reply_created_at.nil?
|
||||
end
|
||||
|
||||
def route_candidate(faq)
|
||||
embedding = embedding_service.get_embedding(candidate_text(faq))
|
||||
def find_and_separate_duplicates(faqs)
|
||||
duplicate_faqs = []
|
||||
unique_faqs = []
|
||||
|
||||
return discard_observation(faq) if matching_record(approved_faqs, faq, embedding)
|
||||
return discard_observation(faq) if matching_record(dismissed_suggestions_for_language, faq, embedding)
|
||||
faqs.each do |faq|
|
||||
combined_text = "#{faq['question']}: #{faq['answer']}"
|
||||
embedding = Captain::Llm::EmbeddingService.new(account_id: @conversation.account_id).get_embedding(combined_text)
|
||||
similar_faqs = find_similar_faqs(embedding)
|
||||
|
||||
suggestion = matching_record(open_suggestions_for_language, faq, embedding)
|
||||
matched_content = suggestion&.slice('question', 'answer')
|
||||
suggestion ||= assistant.faq_suggestions.create!(
|
||||
question: faq.fetch('question'),
|
||||
answer: faq.fetch('answer'),
|
||||
embedding: embedding,
|
||||
language: faq_language
|
||||
)
|
||||
|
||||
attach_observation(suggestion, faq, matched_content)
|
||||
end
|
||||
|
||||
def matching_record(relation, faq, embedding)
|
||||
likely_matches(relation, embedding).find { |record| same_faq?(faq, record) }
|
||||
end
|
||||
|
||||
def likely_matches(relation, embedding)
|
||||
return [] unless relation.exists?
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
# Force an exact search because IVFFlat can miss matches after relation filters.
|
||||
# SET LOCAL keeps the planner change scoped to this transaction.
|
||||
ApplicationRecord.connection.execute('SET LOCAL enable_indexscan = off')
|
||||
relation
|
||||
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
|
||||
.limit(MATCH_LIMIT)
|
||||
.select { |record| record.neighbor_distance < DISTANCE_THRESHOLD }
|
||||
end
|
||||
end
|
||||
|
||||
def same_faq?(candidate, existing_record)
|
||||
comparison = {
|
||||
candidate: candidate.slice('question', 'answer'),
|
||||
existing: { question: existing_record.question, answer: existing_record.answer }
|
||||
}
|
||||
prompt = Captain::Llm::ConversationFaqPromptsService.same_faq
|
||||
faq_match_model = Llm::FeatureRouter.resolve(feature: 'conversation_faq_matching', account: conversation.account)[:model]
|
||||
response = instrument_llm_call(match_instrumentation_params(prompt, comparison, faq_match_model)) do
|
||||
chat(model: faq_match_model)
|
||||
.with_params(response_format: { type: 'json_object' })
|
||||
.with_instructions(prompt)
|
||||
.ask(comparison.to_json)
|
||||
if similar_faqs.any?
|
||||
duplicate_faqs << { faq: faq, similar_faqs: similar_faqs }
|
||||
else
|
||||
unique_faqs << faq
|
||||
end
|
||||
end
|
||||
|
||||
same_faq = JSON.parse(sanitize_json_response(response.content)).fetch('same_faq')
|
||||
raise TypeError, 'same_faq must be a boolean' unless [true, false].include?(same_faq)
|
||||
|
||||
same_faq
|
||||
rescue JSON::ParserError, KeyError, TypeError, RubyLLM::Error => e
|
||||
Rails.logger.error "FAQ match failed: #{e.message}"
|
||||
raise
|
||||
[duplicate_faqs, unique_faqs]
|
||||
end
|
||||
|
||||
def attach_observation(suggestion, faq, matched_content)
|
||||
suggestion.with_lock do
|
||||
next unless suggestion.open?
|
||||
raise SuggestionChangedError if matched_content && suggestion.slice('question', 'answer') != matched_content
|
||||
def find_similar_faqs(embedding)
|
||||
similar_faqs = assistant
|
||||
.responses
|
||||
.nearest_neighbors(:embedding, embedding, distance: 'cosine')
|
||||
Rails.logger.debug(similar_faqs.map { |faq| [faq.question, faq.neighbor_distance] })
|
||||
similar_faqs.select { |record| record.neighbor_distance < DISTANCE_THRESHOLD }
|
||||
end
|
||||
|
||||
existing_observation = suggestion.observations.find_by(conversation: conversation)
|
||||
next existing_observation if existing_observation
|
||||
|
||||
observation = suggestion.observations.create!(
|
||||
conversation: conversation,
|
||||
generated_question: faq.fetch('question'),
|
||||
generated_answer: faq.fetch('answer'),
|
||||
language: faq_language,
|
||||
status: :attached
|
||||
def save_new_faqs(faqs)
|
||||
faqs.map do |faq|
|
||||
assistant.responses.create!(
|
||||
question: faq['question'],
|
||||
answer: faq['answer'],
|
||||
status: 'pending',
|
||||
documentable: conversation
|
||||
)
|
||||
suggestion.update!(source_count: suggestion.source_count + 1)
|
||||
observation
|
||||
end
|
||||
end
|
||||
|
||||
def discard_observation(faq)
|
||||
Captain::FaqObservation.find_or_create_by!(
|
||||
conversation: conversation,
|
||||
generated_question: faq.fetch('question'),
|
||||
generated_answer: faq.fetch('answer'),
|
||||
language: faq_language,
|
||||
status: :discarded
|
||||
)
|
||||
end
|
||||
def log_duplicate_faqs(duplicate_faqs)
|
||||
return if duplicate_faqs.empty?
|
||||
|
||||
def open_suggestions_for_language
|
||||
assistant.faq_suggestions.where(account_id: conversation.account_id).open.by_language(faq_language)
|
||||
end
|
||||
|
||||
def dismissed_suggestions_for_language
|
||||
assistant.faq_suggestions.where(account_id: conversation.account_id).dismissed.by_language(faq_language)
|
||||
end
|
||||
|
||||
def approved_faqs
|
||||
assistant.responses.approved
|
||||
end
|
||||
|
||||
def candidate_text(faq)
|
||||
"#{faq.fetch('question')}: #{faq.fetch('answer')}"
|
||||
Rails.logger.info "Found #{duplicate_faqs.length} duplicate FAQs:"
|
||||
duplicate_faqs.each do |duplicate|
|
||||
Rails.logger.info(
|
||||
"Q: #{duplicate[:faq]['question']}\n" \
|
||||
"A: #{duplicate[:faq]['answer']}\n\n" \
|
||||
"Similar existing FAQs: #{duplicate[:similar_faqs].map { |f| "Q: #{f.question} A: #{f.answer}" }.join(', ')}"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def generate
|
||||
response = instrument_llm_call(generation_instrumentation_params) do
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
chat
|
||||
.with_params(response_format: { type: 'json_object' })
|
||||
.with_instructions(system_prompt)
|
||||
.ask(content)
|
||||
.ask(@content)
|
||||
end
|
||||
parse_generation_response(response.content)
|
||||
parse_response(response.content)
|
||||
rescue RubyLLM::Error => e
|
||||
Rails.logger.error "LLM API Error: #{e.message}"
|
||||
[]
|
||||
end
|
||||
|
||||
def generation_instrumentation_params
|
||||
def instrumentation_params
|
||||
{
|
||||
span_name: 'llm.captain.conversation_faq',
|
||||
model: model,
|
||||
temperature: temperature,
|
||||
account_id: conversation.account_id,
|
||||
conversation_id: conversation.display_id,
|
||||
model: @model,
|
||||
temperature: @temperature,
|
||||
account_id: @conversation.account_id,
|
||||
conversation_id: @conversation.display_id,
|
||||
feature_name: 'conversation_faq',
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: content }
|
||||
{ role: 'user', content: @content }
|
||||
],
|
||||
metadata: { assistant_id: assistant.id, language: faq_language }
|
||||
}
|
||||
end
|
||||
|
||||
def match_instrumentation_params(prompt, comparison, faq_match_model)
|
||||
{
|
||||
span_name: 'llm.captain.faq_match',
|
||||
model: faq_match_model,
|
||||
temperature: temperature,
|
||||
account_id: conversation.account_id,
|
||||
conversation_id: conversation.display_id,
|
||||
feature_name: 'conversation_faq_match',
|
||||
messages: [
|
||||
{ role: 'system', content: prompt },
|
||||
{ role: 'user', content: comparison.to_json }
|
||||
],
|
||||
metadata: { assistant_id: assistant.id, language: faq_language }
|
||||
metadata: { assistant_id: @assistant.id }
|
||||
}
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
Captain::Llm::ConversationFaqPromptsService.generator(language_name(faq_language))
|
||||
account_language = @conversation.account.locale_english_name
|
||||
Captain::Llm::SystemPromptsService.conversation_faq_generator(account_language)
|
||||
end
|
||||
|
||||
def faq_language
|
||||
@faq_language ||= self.class.language_for(conversation)
|
||||
end
|
||||
|
||||
def language_name(language)
|
||||
ISO_639.find(language)&.english_name&.downcase || 'english'
|
||||
end
|
||||
|
||||
def parse_generation_response(response)
|
||||
def parse_response(response)
|
||||
return [] if response.nil?
|
||||
|
||||
JSON.parse(sanitize_json_response(response)).fetch('faqs', [])
|
||||
|
||||
@@ -51,6 +51,62 @@ class Captain::Llm::SystemPromptsService
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def conversation_faq_generator(language = 'english')
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
You create high-quality FAQ candidates from resolved support conversations.
|
||||
Only generate an FAQ when the conversation contains durable, reusable knowledge that would help many future customers.
|
||||
|
||||
## Source rules
|
||||
- The conversation history contains only customer messages and human support agent messages.
|
||||
- Base every FAQ strictly on information stated in the human support agent messages. Do not infer, generalize, or add external knowledge.
|
||||
- A human support agent must state every fact used in the FAQ answer. Customer messages cannot supply missing answer facts.
|
||||
- The human support agent must provide the final answer. If the agent only greets, asks clarifying questions, asks for contact details, promises to check, shares an attachment, or transfers the conversation, return: `{"faqs":[]}`.
|
||||
- For each FAQ, first identify the exact human support agent message that fully answers it. If no single human agent message gives a complete public answer, remove that FAQ.
|
||||
|
||||
## Decision gate
|
||||
Return `{"faqs":[]}` unless every generated FAQ can pass all of these checks:
|
||||
1. The answer is fully stated by a human support agent, not by the customer.
|
||||
2. The answer is a public, durable rule or procedure, not a private account action, manual review, troubleshooting session, quote, file, link, or follow-up.
|
||||
3. The answer can be written without private identifiers, customer-specific facts, direct URLs, attachments, invoices, screenshots, or support-ticket steps.
|
||||
4. The question would still make sense in a help center if the original conversation, customer, and agent did not exist.
|
||||
Do not rescue a rejected conversation by rewriting it as a generic support question.
|
||||
|
||||
## Return no FAQ for
|
||||
- Spam, scams, advertisements, SEO/link-building pitches, adult/gambling/financial promotions, gibberish, abusive content, or conversations unrelated to the business being supported.
|
||||
- Account-specific, order-specific, payment-specific, subscription-specific, login/access, verification, delivery, certificate, or troubleshooting issues, even if they could be rewritten as a general support question.
|
||||
- Conversations that mainly hand off to a human, ask the customer to wait, request private identifiers or contact details, collect screenshots, attachments, or documents, or tell the customer to contact support for case review.
|
||||
- Temporary workarounds, one-off exceptions, unclear answers, unresolved problems, wrong-service conversations, complaints, greetings, or abandoned conversations.
|
||||
- Internal support workflow details, chat session rules, escalation mechanics, ticket-routing instructions, or "someone will get back to you" messages.
|
||||
- Answers that are just a direct/private link, attachment, file, invoice, one-off quote or estimate, account-specific URL, or instructions to open a support ticket.
|
||||
- Questions whose useful answer is "contact support", "wait for the team", "share your details", "we will check", or "this needs manual review".
|
||||
- Questions about whether support can help with a private issue, third-party service, transaction, payment, delivery, or account problem.
|
||||
- Pricing, policy, availability, roadmap, deadline, or legal claims unless the human support agent gives a clear and stable answer in the conversation.
|
||||
- Questions already answered only by asking the customer for more information.
|
||||
|
||||
## FAQ quality rules
|
||||
- Prefer returning no FAQ over a weak or narrow FAQ.
|
||||
- A good candidate teaches a generally reusable product, service, policy, setup, or process rule that another customer could use without contacting support.
|
||||
- Generate at most one FAQ unless the human agent clearly answered multiple distinct, reusable questions.
|
||||
- Do not create duplicate or overlapping FAQs in the same response.
|
||||
- Questions must be general enough for a help center, not personalized to the current customer.
|
||||
- Remove customer names, order numbers, invoice numbers, IDs, private URLs, phone numbers, emails, screenshots, attachments, and other personal or transaction-specific details.
|
||||
- Answers must be complete, self-contained, and supported by the human agent's messages.
|
||||
|
||||
## Examples
|
||||
- Customer mentions a price or procedure, then the human agent only greets or says they will check: return `{"faqs":[]}`.
|
||||
- Human agent shares only a private link, file, invoice, quote, screenshot, or attachment: return `{"faqs":[]}`.
|
||||
- Human agent clearly states a public rule, such as which purchases are allowed for a program or service: generate one general FAQ.
|
||||
|
||||
Generate the FAQs only in the #{language}, use no other language.
|
||||
If no suitable reusable FAQ is available, return: `{"faqs":[]}`.
|
||||
|
||||
Return only valid JSON in this exact structure:
|
||||
```json
|
||||
{ "faqs": [ { "question": "", "answer": "" } ] }
|
||||
```
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def notes_generator(language = 'english')
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
You are a note taker looking to convert the conversation with a contact into actionable notes for the CRM.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
json.partial! 'api/v1/models/captain/assistant_response', formats: [:json], resource: @response
|
||||
@@ -1 +0,0 @@
|
||||
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion
|
||||
@@ -1,10 +0,0 @@
|
||||
json.payload do
|
||||
json.array! @suggestions do |suggestion|
|
||||
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: suggestion
|
||||
end
|
||||
end
|
||||
|
||||
json.meta do
|
||||
json.total_count @suggestions_count
|
||||
json.page @current_page
|
||||
end
|
||||
@@ -1,6 +0,0 @@
|
||||
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion
|
||||
json.observations do
|
||||
json.array! @observations do |observation|
|
||||
json.partial! 'api/v1/models/captain/faq_observation', formats: [:json], resource: observation
|
||||
end
|
||||
end
|
||||
@@ -1 +0,0 @@
|
||||
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion
|
||||
@@ -1,10 +0,0 @@
|
||||
json.id resource.id
|
||||
json.generated_question resource.generated_question
|
||||
json.generated_answer resource.generated_answer
|
||||
json.language resource.language
|
||||
json.status resource.status
|
||||
json.created_at resource.created_at.to_i
|
||||
json.conversation do
|
||||
json.id resource.conversation.id
|
||||
json.display_id resource.conversation.display_id
|
||||
end
|
||||
@@ -1,12 +0,0 @@
|
||||
json.id resource.id
|
||||
json.account_id resource.account_id
|
||||
json.question resource.question
|
||||
json.answer resource.answer
|
||||
json.language resource.language
|
||||
json.source_count resource.source_count
|
||||
json.status resource.status
|
||||
json.created_at resource.created_at.to_i
|
||||
json.updated_at resource.updated_at.to_i
|
||||
json.assistant do
|
||||
json.partial! 'api/v1/models/captain/assistant', formats: [:json], resource: resource.assistant
|
||||
end
|
||||
@@ -89,7 +89,6 @@ module Redis::RedisKeys
|
||||
WHATSAPP_MESSAGE_MUTEX = 'WHATSAPP_MESSAGE_CREATE_LOCK::%<inbox_id>s::%<sender_id>s'.freeze
|
||||
CRM_PROCESS_MUTEX = 'CRM_PROCESS_MUTEX::%<hook_id>s'.freeze
|
||||
CAPTAIN_DOCUMENT_SYNC_MUTEX = 'CAPTAIN_DOCUMENT_SYNC_LOCK::%<document_id>s'.freeze
|
||||
CAPTAIN_CONVERSATION_FAQ_MUTEX = 'CAPTAIN_CONVERSATION_FAQ_LOCK::%<assistant_id>s::%<language>s'.freeze
|
||||
|
||||
## Auto Assignment Keys
|
||||
# Track conversation assignments to agents for rate limiting
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.16.1",
|
||||
"version": "4.16.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
@@ -34,7 +34,7 @@
|
||||
"@amplitude/analytics-browser": "^2.11.10",
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.3.23",
|
||||
"@chatwoot/prosemirror-schema": "1.3.22",
|
||||
"@chatwoot/utils": "^0.0.56",
|
||||
"@formkit/core": "^1.7.2",
|
||||
"@formkit/vue": "^1.7.2",
|
||||
@@ -86,6 +86,9 @@
|
||||
"mitt": "^3.0.1",
|
||||
"opus-recorder": "^8.0.5",
|
||||
"pinia": "^3.0.4",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "7.6.3",
|
||||
"snakecase-keys": "^8.0.1",
|
||||
|
||||
Generated
+22
-6
@@ -25,8 +25,8 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.3.23
|
||||
version: 1.3.23
|
||||
specifier: 1.3.22
|
||||
version: 1.3.22
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.56
|
||||
version: 0.0.56
|
||||
@@ -180,6 +180,15 @@ importers:
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2))
|
||||
prosemirror-commands:
|
||||
specifier: ^1.7.1
|
||||
version: 1.7.1
|
||||
prosemirror-inputrules:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
prosemirror-schema-list:
|
||||
specifier: ^1.5.1
|
||||
version: 1.5.1
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
@@ -452,8 +461,8 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.23':
|
||||
resolution: {integrity: sha512-jGxbWELCdlVI64BJiE1wT84ekJHYDXXKiluQIKT3aKPEjPwMR48umKF3A0yHjKoR7IIxCC9oM77TvXOA0ebLtw==}
|
||||
'@chatwoot/prosemirror-schema@1.3.22':
|
||||
resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==}
|
||||
|
||||
'@chatwoot/utils@0.0.56':
|
||||
resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==}
|
||||
@@ -3992,6 +4001,9 @@ packages:
|
||||
prosemirror-tables@1.5.0:
|
||||
resolution: {integrity: sha512-VMx4zlYWm7aBlZ5xtfJHpqa3Xgu3b7srV54fXYnXgsAcIGRqKSrhiK3f89omzzgaAgAtDOV4ImXnLKhVfheVNQ==}
|
||||
|
||||
prosemirror-transform@1.10.0:
|
||||
resolution: {integrity: sha512-9UOgFSgN6Gj2ekQH5CTDJ8Rp/fnKR2IkYfGdzzp5zQMFsS4zDllLVx/+jGcX86YlACpG7UR5fwAXiWzxqWtBTg==}
|
||||
|
||||
prosemirror-transform@1.12.0:
|
||||
resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
|
||||
|
||||
@@ -5124,7 +5136,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.3.23':
|
||||
'@chatwoot/prosemirror-schema@1.3.22':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.7.1
|
||||
@@ -9023,7 +9035,7 @@ snapshots:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
prosemirror-state: 1.4.3
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-transform: 1.10.0
|
||||
|
||||
prosemirror-state@1.4.3:
|
||||
dependencies:
|
||||
@@ -9039,6 +9051,10 @@ snapshots:
|
||||
prosemirror-transform: 1.12.0
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
prosemirror-transform@1.10.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
|
||||
prosemirror-transform@1.12.0:
|
||||
dependencies:
|
||||
prosemirror-model: 1.22.3
|
||||
|
||||
@@ -23,12 +23,6 @@ RSpec.describe AgentBuilder, type: :model do
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'locks the account while checking and creating the agent' do
|
||||
expect(account).to receive(:with_lock).and_call_original
|
||||
|
||||
agent_builder.perform
|
||||
end
|
||||
|
||||
context 'when user does not exist' do
|
||||
it 'creates a new user' do
|
||||
expect { agent_builder.perform }.to change(User, :count).by(1)
|
||||
@@ -73,17 +67,5 @@ RSpec.describe AgentBuilder, type: :model do
|
||||
expect(user.encrypted_password).not_to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account has reached its agent limit' do
|
||||
before do
|
||||
allow(account).to receive(:usage_limits).and_return({ agents: account.account_users.count })
|
||||
end
|
||||
|
||||
it 'raises a limit exceeded error without creating a user' do
|
||||
expect { agent_builder.perform }.to raise_error(described_class::LimitExceededError, described_class::LIMIT_EXCEEDED_MESSAGE)
|
||||
|
||||
expect(User.from_email(email)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,9 +13,7 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/:account_id/integrations/linear' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'deletes the linear integration when the user is an administrator' do
|
||||
it 'deletes the linear integration' do
|
||||
# Stub the HTTP call to Linear's revoke endpoint
|
||||
allow(HTTParty).to receive(:post).with(
|
||||
'https://api.linear.app/oauth/revoke',
|
||||
@@ -23,19 +21,11 @@ RSpec.describe 'Linear Integration API', type: :request do
|
||||
).and_return(instance_double(HTTParty::Response, success?: true))
|
||||
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
||||
headers: admin.create_new_auth_token,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(account.hooks.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'returns unauthorized for an agent and keeps the integration' do
|
||||
delete "/api/v1/accounts/#{account.id}/integrations/linear",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(account.hooks.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/integrations/linear/teams' do
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user