Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62a990e1fe | ||
|
|
bbd7a46618 | ||
|
|
8d068dbac6 | ||
|
|
be73363b6e | ||
|
|
7a76cee723 | ||
|
|
4842f724ea | ||
|
|
8c28fb74ec | ||
|
|
2ec33da5cd | ||
|
|
63d765b14b | ||
|
|
b11f2e4a0c | ||
|
|
d5a9747b81 | ||
|
|
a8ef787878 | ||
|
|
d85ab03444 | ||
|
|
b4d2fa5554 | ||
|
|
f1753f08b2 | ||
|
|
fc82c7939e | ||
|
|
45e065eca8 | ||
|
|
1ee939a910 | ||
|
|
3ffbaf680c | ||
|
|
837343b12d | ||
|
|
9f658fbfaa | ||
|
|
c183b2aa80 | ||
|
|
9a780e8716 | ||
|
|
e48d612a55 | ||
|
|
3662224396 | ||
|
|
41082f845f | ||
|
|
d054c19770 | ||
|
|
961c4a8951 | ||
|
|
9d8af17b95 | ||
|
|
439ac80019 | ||
|
|
28fb05005d | ||
|
|
84c41f03d6 | ||
|
|
a17734676c | ||
|
|
70a63695c3 | ||
|
|
325c306a1e | ||
|
|
e0aa099487 | ||
|
|
398b84d5de | ||
|
|
c25aac7a99 | ||
|
|
4c947f9260 | ||
|
|
cf417a2f3e | ||
|
|
11143672a6 | ||
|
|
2485bc242d | ||
|
|
412319462a | ||
|
|
4d22d2c18e | ||
|
|
8600e9b170 | ||
|
|
64d2751c06 | ||
|
|
f6d97c56b4 | ||
|
|
fc63c7bddf | ||
|
|
916b510ecf | ||
|
|
4a73de1581 | ||
|
|
d09ece0b85 | ||
|
|
68f60b0672 | ||
|
|
e1445e4c2d | ||
|
|
2579e2b93b | ||
|
|
f0edabebb1 | ||
|
|
3a36f096d2 | ||
|
|
5264a18dc8 | ||
|
|
30e50c00e8 | ||
|
|
2e84ebb4de | ||
|
|
cf1fdff04b | ||
|
|
f04eb85175 | ||
|
|
637eddad34 | ||
|
|
d3e1ad56ac | ||
|
|
b95e75f4ca | ||
|
|
44ad817fb3 | ||
|
|
f98cfd2184 | ||
|
|
7723f19816 | ||
|
|
69216db45d | ||
|
|
c272de5be9 | ||
|
|
f6f688acbb | ||
|
|
61f5198b99 | ||
|
|
1dd91cda92 | ||
|
|
c3635128ef | ||
|
|
cb5b4a539c | ||
|
|
dfb93ef58f | ||
|
|
d1ab6edaf1 | ||
|
|
07dc14b3fa |
@@ -79,21 +79,13 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
params.require(:portal).permit(
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived,
|
||||
{ config: config_param_keys }
|
||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
|
||||
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
|
||||
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
|
||||
)
|
||||
end
|
||||
|
||||
def config_param_keys
|
||||
keys = [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
|
||||
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
|
||||
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }]
|
||||
# Analytics injects tracking scripts into every public page, so keep it admin-only even though
|
||||
# Enterprise lets knowledge_base_manage roles edit other portal settings.
|
||||
keys << { analytics: %i[ga4 gtm clarity hotjar meta_pixel] } if Current.account_user&.administrator?
|
||||
keys
|
||||
end
|
||||
|
||||
def locale_translation_keys
|
||||
params.dig(:portal, :config, :locale_translations)&.keys || []
|
||||
end
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* 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, status } = {}) {
|
||||
get({ page = 1, search, assistantId, documentId, signal } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
search,
|
||||
assistant_id: assistantId,
|
||||
document_id: documentId,
|
||||
status,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
-6
@@ -28,12 +28,7 @@ const inboxes = computed(() => {
|
||||
return {
|
||||
name: inbox.name,
|
||||
id: inbox.id,
|
||||
icon: getInboxIconByType(
|
||||
inbox.channelType,
|
||||
inbox.medium,
|
||||
'line',
|
||||
inbox.voiceEnabled
|
||||
),
|
||||
icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { getInboxVoiceIcon } from 'dashboard/helper/inbox';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
|
||||
@@ -61,7 +60,7 @@ const resultLabel = computed(() => {
|
||||
});
|
||||
|
||||
const providerIcon = computed(() =>
|
||||
getInboxVoiceIcon(props.call.inbox.channelType, props.call.inbox.medium)
|
||||
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
|
||||
);
|
||||
|
||||
const createdAtLabel = computed(() =>
|
||||
@@ -151,7 +150,7 @@ const conversationRoute = computed(() => ({
|
||||
<div
|
||||
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 min-w-0 w-52 shrink-0 py-3.5">
|
||||
<div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5">
|
||||
<Avatar
|
||||
:src="call.contact.avatar"
|
||||
:name="contactName"
|
||||
@@ -170,12 +169,9 @@ const conversationRoute = computed(() => ({
|
||||
>
|
||||
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
|
||||
<CallStatusBadge :kind="kind" class="shrink-0" />
|
||||
<div
|
||||
v-if="agentActionLabel"
|
||||
class="gap-x-1.5 min-w-0 flex items-center"
|
||||
>
|
||||
<template v-if="agentActionLabel">
|
||||
<span
|
||||
class="text-label-small text-n-slate-10 truncate shrink min-w-8 xl:min-w-14"
|
||||
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
|
||||
>
|
||||
{{ agentActionLabel }}
|
||||
</span>
|
||||
@@ -196,7 +192,7 @@ const conversationRoute = computed(() => ({
|
||||
{{ call.agent.name }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<span
|
||||
v-else-if="resultLabel"
|
||||
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
|
||||
@@ -216,10 +212,10 @@ const conversationRoute = computed(() => ({
|
||||
content: call.inbox.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="flex items-center gap-1 justify-end w-40 min-w-4 shrink-[100] py-3.5"
|
||||
class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5"
|
||||
>
|
||||
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
|
||||
<span class="text-body-main truncate text-n-slate-11 min-w-0">
|
||||
<span class="text-body-main truncate text-n-slate-11">
|
||||
{{ call.inbox.name }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -236,7 +232,7 @@ const conversationRoute = computed(() => ({
|
||||
content: createdAtLabel,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end min-w-16 max-w-20 shrink-0"
|
||||
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0"
|
||||
>
|
||||
{{ createdAtLabel }}
|
||||
</span>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
// Null while a fetch is in flight so stale counts are never shown.
|
||||
@@ -118,16 +117,10 @@ const moreFiltersSections = computed(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
const selectedAssignee = computed(
|
||||
() => props.agents.find(agent => agent.id === assigneeId.value) || null
|
||||
);
|
||||
|
||||
const selectedAssigneeLabel = computed(
|
||||
() => selectedAssignee.value?.name || t('CALLS_PAGE.FILTERS.ASSIGNEE')
|
||||
);
|
||||
|
||||
const isOtherActivitySelected = computed(() =>
|
||||
OTHER_ACTIVITIES.includes(activity.value)
|
||||
() =>
|
||||
props.agents.find(agent => agent.id === assigneeId.value)?.name ||
|
||||
t('CALLS_PAGE.FILTERS.ASSIGNEE')
|
||||
);
|
||||
|
||||
const hasMoreFilters = computed(() => Boolean(inboxId.value));
|
||||
@@ -150,7 +143,7 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
|
||||
{{
|
||||
totalCount === null
|
||||
@@ -164,13 +157,13 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
color="blue"
|
||||
size="sm"
|
||||
:icon="ACTIVITY_ICONS[activity]"
|
||||
class="shrink-0 !h-7 !px-2"
|
||||
class="shrink-0"
|
||||
@click="setActivity(null)"
|
||||
>
|
||||
{{ activeChipLabel }}
|
||||
<Icon icon="i-lucide-x" />
|
||||
</Button>
|
||||
<div class="w-px h-3.5 mx-1 bg-n-strong shrink-0" />
|
||||
<div class="w-px h-4 bg-n-strong shrink-0" />
|
||||
<Button
|
||||
v-for="chip in inactiveChips"
|
||||
:key="chip"
|
||||
@@ -179,7 +172,7 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
size="sm"
|
||||
:icon="ACTIVITY_ICONS[chip]"
|
||||
:label="activityLabel(chip)"
|
||||
class="shrink-0 text-n-slate-11 !h-7 !px-2"
|
||||
class="shrink-0 text-n-slate-12"
|
||||
@click="setActivity(chip)"
|
||||
/>
|
||||
<OnClickOutside
|
||||
@@ -191,10 +184,7 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-phone"
|
||||
class="!h-7 !px-2"
|
||||
:class="
|
||||
isOtherActivitySelected ? 'text-n-slate-12' : 'text-n-slate-11'
|
||||
"
|
||||
class="text-n-slate-12"
|
||||
@click="toggleMenu('activity')"
|
||||
>
|
||||
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
|
||||
@@ -218,19 +208,10 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
variant="outline"
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-woot-empty-assignee"
|
||||
class="max-w-52 !h-7 !px-2"
|
||||
:class="assigneeId ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
icon="i-lucide-user-round-cog"
|
||||
class="max-w-52 text-n-slate-12"
|
||||
@click="toggleMenu('assignee')"
|
||||
>
|
||||
<template v-if="selectedAssignee" #icon>
|
||||
<Avatar
|
||||
:src="selectedAssignee.thumbnail"
|
||||
:name="selectedAssignee.name"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
</template>
|
||||
<span class="truncate">{{ selectedAssigneeLabel }}</span>
|
||||
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
|
||||
</Button>
|
||||
@@ -247,9 +228,8 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-list-filter"
|
||||
class="!h-7 !px-2"
|
||||
:color="hasMoreFilters ? 'blue' : 'slate'"
|
||||
:class="hasMoreFilters ? '' : 'text-n-slate-11'"
|
||||
:class="hasMoreFilters ? '' : 'text-n-slate-12'"
|
||||
@click="toggleMenu('more')"
|
||||
>
|
||||
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
|
||||
|
||||
@@ -83,12 +83,8 @@ const campaignStatus = computed(() => {
|
||||
const inboxName = computed(() => props.inbox?.name || '');
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const {
|
||||
medium,
|
||||
channel_type: type,
|
||||
voice_enabled: voiceEnabled,
|
||||
} = props.inbox;
|
||||
return getInboxIconByType(type, medium, 'fill', voiceEnabled);
|
||||
const { medium, channel_type: type } = props.inbox;
|
||||
return getInboxIconByType(type, medium);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
+2
-2
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
|
||||
const inboxName = computed(() => inbox.value?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
});
|
||||
|
||||
const lastActivityAt = computed(() => {
|
||||
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Select from 'dashboard/components-next/select/Select.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activePortal: { type: Object, required: true },
|
||||
isFetching: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updatePortalConfiguration']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// `pattern` mirrors Portal::ANALYTICS_PROVIDERS to flag bad ids before saving.
|
||||
const PROVIDERS = computed(() => [
|
||||
{
|
||||
key: 'ga4',
|
||||
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.GA4.LABEL'),
|
||||
placeholder: 'G-XXXXXXXXXX',
|
||||
pattern: /^G-[A-Z0-9]+$/,
|
||||
},
|
||||
{
|
||||
key: 'gtm',
|
||||
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.GTM.LABEL'),
|
||||
placeholder: 'GTM-XXXXXXX',
|
||||
pattern: /^GTM-[A-Z0-9]+$/,
|
||||
},
|
||||
{
|
||||
key: 'clarity',
|
||||
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.CLARITY.LABEL'),
|
||||
placeholder: 'abcd1234ef',
|
||||
pattern: /^[a-z0-9]+$/,
|
||||
},
|
||||
{
|
||||
key: 'hotjar',
|
||||
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.HOTJAR.LABEL'),
|
||||
placeholder: '1234567',
|
||||
pattern: /^\d+$/,
|
||||
},
|
||||
{
|
||||
key: 'meta_pixel',
|
||||
name: t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.META_PIXEL.LABEL'),
|
||||
placeholder: '1234567890',
|
||||
pattern: /^\d+$/,
|
||||
},
|
||||
]);
|
||||
|
||||
const portalConfig = computed(() => props.activePortal?.config || {});
|
||||
const providerFor = key =>
|
||||
PROVIDERS.value.find(provider => provider.key === key);
|
||||
|
||||
const state = reactive({ provider: PROVIDERS.value[0].key, id: '' });
|
||||
let saved = { provider: '', id: '' };
|
||||
|
||||
const resetFromPortal = () => {
|
||||
const analytics = portalConfig.value.analytics || {};
|
||||
const [key, value] = Object.entries(analytics)[0] || [];
|
||||
saved = { provider: key || '', id: value || '' };
|
||||
state.provider = saved.provider || PROVIDERS.value[0].key;
|
||||
state.id = saved.id;
|
||||
};
|
||||
|
||||
watch(() => props.activePortal, resetFromPortal, {
|
||||
immediate: true,
|
||||
deep: true,
|
||||
});
|
||||
|
||||
// Clear the id when switching to a provider other than the saved one.
|
||||
watch(
|
||||
() => state.provider,
|
||||
provider => {
|
||||
state.id = provider === saved.provider ? saved.id : '';
|
||||
}
|
||||
);
|
||||
|
||||
const providerOptions = computed(() =>
|
||||
PROVIDERS.value.map(provider => ({
|
||||
value: provider.key,
|
||||
label: provider.name,
|
||||
}))
|
||||
);
|
||||
|
||||
const currentProvider = computed(() => providerFor(state.provider));
|
||||
|
||||
const error = computed(() => {
|
||||
const value = state.id.trim();
|
||||
if (!value || currentProvider.value.pattern.test(value)) return '';
|
||||
return t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.INVALID_ID');
|
||||
});
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
const id = state.id.trim();
|
||||
if (!id) return saved.id !== '';
|
||||
return id !== saved.id || state.provider !== saved.provider;
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
const id = state.id.trim();
|
||||
const analytics = id ? { [state.provider]: id } : {};
|
||||
emit('updatePortalConfiguration', {
|
||||
id: props.activePortal.id,
|
||||
slug: props.activePortal.slug,
|
||||
config: { analytics },
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.HEADER') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.PROVIDER') }}
|
||||
</label>
|
||||
<Select v-model="state.provider" :options="providerOptions" />
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="state.id"
|
||||
:label="currentProvider.name"
|
||||
:placeholder="currentProvider.placeholder"
|
||||
:message="error"
|
||||
:message-type="error ? 'error' : 'info'"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.PORTAL_SETTINGS.ANALYTICS.SAVE')"
|
||||
:disabled="!hasChanges || Boolean(error) || isFetching"
|
||||
@click="handleSave"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-11
@@ -3,13 +3,11 @@ import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
|
||||
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
|
||||
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
|
||||
import PortalAnalyticsSettings from './PortalAnalyticsSettings.vue';
|
||||
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -35,7 +33,6 @@ const emit = defineEmits([
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const confirmDeletePortalDialogRef = ref(null);
|
||||
|
||||
@@ -111,14 +108,6 @@ const handleDeletePortal = () => {
|
||||
:is-fetching="isFetching"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
/>
|
||||
<template v-if="isAdmin">
|
||||
<div class="w-full h-px bg-n-weak" />
|
||||
<PortalAnalyticsSettings
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
/>
|
||||
</template>
|
||||
<div class="w-full h-px bg-n-weak" />
|
||||
<div class="flex items-end justify-between w-full gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
|
||||
const inbox = computed(() => props.stateInbox);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
});
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
|
||||
+1
-3
@@ -37,11 +37,10 @@ const transformInbox = ({
|
||||
channelType,
|
||||
phoneNumber,
|
||||
medium,
|
||||
voiceEnabled,
|
||||
...rest
|
||||
}) => ({
|
||||
id,
|
||||
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
|
||||
icon: getInboxIconByType(channelType, medium, 'line'),
|
||||
label: generateLabelForContactableInboxesList({
|
||||
name,
|
||||
email,
|
||||
@@ -55,7 +54,6 @@ const transformInbox = ({
|
||||
phoneNumber,
|
||||
channelType,
|
||||
medium,
|
||||
voiceEnabled,
|
||||
...rest,
|
||||
});
|
||||
|
||||
|
||||
-14
@@ -79,20 +79,6 @@ describe('composeConversationHelper', () => {
|
||||
channelType: INBOX_TYPES.EMAIL,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the voice glyph for a voice-enabled inbox', () => {
|
||||
const inboxes = [
|
||||
{
|
||||
id: 2,
|
||||
name: 'WhatsApp Cloud',
|
||||
channelType: INBOX_TYPES.WHATSAPP,
|
||||
voiceEnabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const result = helpers.buildContactableInboxesList(inboxes);
|
||||
expect(result[0].icon).toBe('i-woot-whatsapp-voice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCapitalizedNameFromEmail', () => {
|
||||
|
||||
@@ -117,8 +117,8 @@ const downloadRecording = () => {
|
||||
>
|
||||
<template #icon>
|
||||
<Icon
|
||||
:icon="isPlaying ? 'i-woot-audio-pause' : 'i-woot-audio-play'"
|
||||
class="size-4 flex-shrink-0 text-n-slate-11"
|
||||
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
@@ -127,10 +127,10 @@ const downloadRecording = () => {
|
||||
min="0"
|
||||
:max="duration || 0"
|
||||
:value="currentTime"
|
||||
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-0.5 rounded-full appearance-none cursor-pointer bg-n-slate-12/30 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-n-slate-11 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-2 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-n-slate-11"
|
||||
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
|
||||
@input="seek"
|
||||
/>
|
||||
<span class="text-label-small tabular-nums text-n-slate-11 shrink-0">
|
||||
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
|
||||
{{ displayedTime }}
|
||||
</span>
|
||||
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<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>
|
||||
@@ -58,12 +58,8 @@ const menuItems = computed(() => [
|
||||
]);
|
||||
|
||||
const icon = computed(() => {
|
||||
const {
|
||||
medium,
|
||||
channel_type: type,
|
||||
voice_enabled: voiceEnabled,
|
||||
} = props.inbox;
|
||||
return getInboxIconByType(type, medium, 'outline', voiceEnabled);
|
||||
const { medium, channel_type: type } = props.inbox;
|
||||
return getInboxIconByType(type, medium, 'outline');
|
||||
});
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
|
||||
+5
-39
@@ -7,24 +7,7 @@ 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';
|
||||
|
||||
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 emit = defineEmits(['click']);
|
||||
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
@@ -32,15 +15,10 @@ 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"
|
||||
@@ -50,16 +28,12 @@ const onClearFilters = () => {
|
||||
class="mb-8"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="
|
||||
isPending
|
||||
? $t('CAPTAIN.RESPONSES.EMPTY_STATE.NO_PENDING_TITLE')
|
||||
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
|
||||
"
|
||||
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
|
||||
:action-perms="['administrator']"
|
||||
:show-backdrop="isApproved"
|
||||
show-backdrop
|
||||
>
|
||||
<template v-if="isApproved" #empty-state-item>
|
||||
<template #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)"
|
||||
@@ -77,18 +51,10 @@ const onClearFilters = () => {
|
||||
<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>
|
||||
|
||||
+14
-9
@@ -6,7 +6,12 @@ import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
const props = defineProps({
|
||||
knowledge: {
|
||||
type: Object,
|
||||
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
|
||||
default: () => ({
|
||||
approved: 0,
|
||||
suggestions: 0,
|
||||
documents: 0,
|
||||
coverage: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,16 +36,16 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Thin coverage paired with a large review backlog: approving the pending FAQs
|
||||
// Thin coverage paired with a large review backlog: approving FAQ suggestions
|
||||
// is the quickest lever to lift auto-resolution, so nudge the team to act.
|
||||
const COVERAGE_THRESHOLD = 85;
|
||||
const PENDING_THRESHOLD = 100;
|
||||
const SUGGESTION_THRESHOLD = 100;
|
||||
|
||||
const showBanner = computed(
|
||||
() =>
|
||||
!dismissed.value &&
|
||||
(props.knowledge?.coverage ?? 0) < COVERAGE_THRESHOLD &&
|
||||
(props.knowledge?.pending ?? 0) > PENDING_THRESHOLD
|
||||
(props.knowledge?.suggestions ?? 0) > SUGGESTION_THRESHOLD
|
||||
);
|
||||
|
||||
const dismiss = () => {
|
||||
@@ -48,9 +53,9 @@ const dismiss = () => {
|
||||
dismissed.value = true;
|
||||
};
|
||||
|
||||
const goToPending = () => {
|
||||
const goToSuggestions = () => {
|
||||
router.push({
|
||||
name: 'captain_assistants_responses_pending',
|
||||
name: 'captain_assistants_faq_suggestions',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: route.params.assistantId,
|
||||
@@ -61,15 +66,15 @@ const goToPending = () => {
|
||||
|
||||
<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.pending,
|
||||
count: knowledge.suggestions,
|
||||
coverage: knowledge.coverage,
|
||||
})
|
||||
}}
|
||||
@@ -79,7 +84,7 @@ const goToPending = () => {
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
|
||||
@click="goToPending"
|
||||
@click="goToSuggestions"
|
||||
>
|
||||
{{ $t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.ACTION') }}
|
||||
</button>
|
||||
|
||||
+10
-5
@@ -6,7 +6,12 @@ import { useI18n } from 'vue-i18n';
|
||||
const props = defineProps({
|
||||
knowledge: {
|
||||
type: Object,
|
||||
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
|
||||
default: () => ({
|
||||
approved: 0,
|
||||
suggestions: 0,
|
||||
documents: 0,
|
||||
coverage: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,10 +36,10 @@ const stats = computed(() => [
|
||||
to: linkTo('captain_assistants_responses_index'),
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
value: props.knowledge.pending,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.PENDING'),
|
||||
to: linkTo('captain_assistants_responses_pending'),
|
||||
key: 'suggestions',
|
||||
value: props.knowledge.suggestions,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.SUGGESTIONS'),
|
||||
to: linkTo('captain_assistants_faq_suggestions'),
|
||||
},
|
||||
{
|
||||
key: 'documents',
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
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
@@ -0,0 +1,321 @@
|
||||
<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,16 +24,6 @@ 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 });
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, toRef } from 'vue';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import { useChannelIcon, useChannelBrandIcon } from './provider';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
@@ -20,6 +21,7 @@ defineOptions({ inheritAttrs: false });
|
||||
|
||||
const inboxRef = toRef(props, 'inbox');
|
||||
|
||||
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
|
||||
const channelIcon = useChannelIcon(inboxRef);
|
||||
const brandIcon = useChannelBrandIcon(inboxRef);
|
||||
|
||||
@@ -29,7 +31,13 @@ const icon = computed(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex" v-bind="$attrs">
|
||||
<span class="relative inline-flex" v-bind="$attrs">
|
||||
<Icon :icon="icon" class="size-full" />
|
||||
<span
|
||||
v-if="hasVoiceBadge"
|
||||
class="absolute top-0 ltr:right-0 rtl:left-0 inline-flex items-center justify-center size-2 rounded-full bg-n-surface-1"
|
||||
>
|
||||
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
INBOX_TYPES,
|
||||
TWILIO_CHANNEL_MEDIUM,
|
||||
isVoiceCallEnabled,
|
||||
getInboxVoiceIcon,
|
||||
} from 'dashboard/helper/inbox';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const channelTypeIconMap = {
|
||||
@@ -66,9 +61,16 @@ export function useChannelIcon(inbox) {
|
||||
icon = 'i-woot-whatsapp';
|
||||
}
|
||||
|
||||
// Voice-enabled inboxes use the combined channel + voice-wave badge glyph.
|
||||
if (isVoiceCallEnabled(inboxDetails)) {
|
||||
icon = getInboxVoiceIcon(type, inboxDetails.medium);
|
||||
// Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium)
|
||||
// is presented as a Voice channel, so show the phone icon.
|
||||
const voiceEnabled =
|
||||
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
|
||||
if (
|
||||
type === INBOX_TYPES.TWILIO &&
|
||||
voiceEnabled &&
|
||||
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
) {
|
||||
icon = 'i-woot-voice';
|
||||
}
|
||||
|
||||
return icon ?? 'i-ri-global-fill';
|
||||
|
||||
@@ -19,32 +19,13 @@ describe('useChannelIcon', () => {
|
||||
expect(icon).toBe('i-woot-whatsapp');
|
||||
});
|
||||
|
||||
it('returns the voice-call glyph for a voice-enabled Twilio channel', () => {
|
||||
it('returns correct icon for voice-enabled Twilio channel', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
voice_enabled: true,
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-voice-call');
|
||||
});
|
||||
|
||||
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp channel', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
voice_enabled: true,
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-whatsapp-voice');
|
||||
});
|
||||
|
||||
it('returns the WhatsApp voice glyph for a voice-enabled Twilio WhatsApp channel', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
medium: 'whatsapp',
|
||||
voice_enabled: true,
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-whatsapp-voice');
|
||||
expect(icon).toBe('i-woot-voice');
|
||||
});
|
||||
|
||||
it('returns correct icon for Line channel', () => {
|
||||
|
||||
@@ -457,11 +457,10 @@ function handleReplyTo() {
|
||||
|
||||
const avatarInfo = computed(() => {
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
const { name, avatar_url, channel_type, medium, voice_enabled } =
|
||||
inbox.value;
|
||||
const { name, avatar_url, channel_type, medium } = inbox.value;
|
||||
const iconName = avatar_url
|
||||
? null
|
||||
: getInboxIconByType(channel_type, medium, 'fill', voice_enabled);
|
||||
: getInboxIconByType(channel_type, medium);
|
||||
return {
|
||||
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
|
||||
src: avatar_url || '',
|
||||
|
||||
@@ -511,7 +511,7 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
||||
activeOn: [
|
||||
'captain_assistants_responses_index',
|
||||
'captain_assistants_responses_pending',
|
||||
'captain_assistants_faq_suggestions',
|
||||
],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_responses_index',
|
||||
|
||||
@@ -55,30 +55,11 @@ export const getVoiceCallProvider = inbox => {
|
||||
|
||||
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
|
||||
|
||||
// Combined channel + voice-wave badge glyph per voice-call provider.
|
||||
export const VOICE_CALL_ICONS = {
|
||||
[VOICE_CALL_PROVIDERS.WHATSAPP]: 'i-woot-whatsapp-voice',
|
||||
[VOICE_CALL_PROVIDERS.TWILIO]: 'i-woot-voice-call',
|
||||
};
|
||||
|
||||
export const getVoiceCallIcon = provider =>
|
||||
VOICE_CALL_ICONS[provider] ?? VOICE_CALL_ICONS[VOICE_CALL_PROVIDERS.TWILIO];
|
||||
|
||||
export const TWILIO_CHANNEL_MEDIUM = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
SMS: 'sms',
|
||||
};
|
||||
|
||||
export const getInboxVoiceIcon = (channelType, medium) => {
|
||||
const isWhatsapp =
|
||||
channelType === INBOX_TYPES.WHATSAPP ||
|
||||
(channelType === INBOX_TYPES.TWILIO &&
|
||||
medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP);
|
||||
return getVoiceCallIcon(
|
||||
isWhatsapp ? VOICE_CALL_PROVIDERS.WHATSAPP : VOICE_CALL_PROVIDERS.TWILIO
|
||||
);
|
||||
};
|
||||
|
||||
const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
||||
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
||||
@@ -201,14 +182,7 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getInboxIconByType = (
|
||||
type,
|
||||
medium,
|
||||
variant = 'fill',
|
||||
voiceEnabled = false
|
||||
) => {
|
||||
if (voiceEnabled) return getInboxVoiceIcon(type, medium);
|
||||
|
||||
export const getInboxIconByType = (type, medium, variant = 'fill') => {
|
||||
const iconMap =
|
||||
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
|
||||
const defaultIcon =
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import {
|
||||
INBOX_TYPES,
|
||||
VOICE_CALL_PROVIDERS,
|
||||
getInboxClassByType,
|
||||
getInboxIconByType,
|
||||
getInboxVoiceIcon,
|
||||
getInboxWarningIconClass,
|
||||
getVoiceCallIcon,
|
||||
} from '../inbox';
|
||||
|
||||
describe('#Inbox Helpers', () => {
|
||||
@@ -169,63 +166,4 @@ describe('#Inbox Helpers', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVoiceCallIcon', () => {
|
||||
it('returns the WhatsApp voice glyph for the whatsapp provider', () => {
|
||||
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.WHATSAPP)).toBe(
|
||||
'i-woot-whatsapp-voice'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the generic voice-call glyph for the twilio provider', () => {
|
||||
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.TWILIO)).toBe(
|
||||
'i-woot-voice-call'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the generic voice-call glyph for an unknown provider', () => {
|
||||
expect(getVoiceCallIcon('unknown')).toBe('i-woot-voice-call');
|
||||
expect(getVoiceCallIcon(undefined)).toBe('i-woot-voice-call');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInboxVoiceIcon', () => {
|
||||
it('returns the WhatsApp voice glyph for a WhatsApp inbox', () => {
|
||||
expect(getInboxVoiceIcon(INBOX_TYPES.WHATSAPP)).toBe(
|
||||
'i-woot-whatsapp-voice'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the WhatsApp voice glyph for a Twilio WhatsApp inbox', () => {
|
||||
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
|
||||
'i-woot-whatsapp-voice'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the generic voice-call glyph for a Twilio voice inbox', () => {
|
||||
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'sms')).toBe(
|
||||
'i-woot-voice-call'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInboxIconByType with voice enabled', () => {
|
||||
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp inbox', () => {
|
||||
expect(
|
||||
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', true)
|
||||
).toBe('i-woot-whatsapp-voice');
|
||||
});
|
||||
|
||||
it('returns the generic voice-call glyph for a voice-enabled Twilio inbox', () => {
|
||||
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms', 'line', true)).toBe(
|
||||
'i-woot-voice-call'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the normal channel icon when voice is not enabled', () => {
|
||||
expect(
|
||||
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', false)
|
||||
).toBe('i-woot-whatsapp');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -972,28 +972,6 @@
|
||||
},
|
||||
"SAVE": "Save changes"
|
||||
},
|
||||
"ANALYTICS": {
|
||||
"HEADER": "Analytics",
|
||||
"DESCRIPTION": "Choose an analytics provider and enter its ID. We add the tracking code to every public help center page automatically. Switching providers replaces the previous one.",
|
||||
"PROVIDER": "Provider",
|
||||
"INVALID_ID": "This doesn't look like a valid ID for this provider.",
|
||||
"GA4": {
|
||||
"LABEL": "Google Analytics (GA4)"
|
||||
},
|
||||
"GTM": {
|
||||
"LABEL": "Google Tag Manager"
|
||||
},
|
||||
"CLARITY": {
|
||||
"LABEL": "Microsoft Clarity"
|
||||
},
|
||||
"HOTJAR": {
|
||||
"LABEL": "Hotjar"
|
||||
},
|
||||
"META_PIXEL": {
|
||||
"LABEL": "Meta Pixel"
|
||||
},
|
||||
"SAVE": "Save changes"
|
||||
},
|
||||
"API": {
|
||||
"CREATE_PORTAL": {
|
||||
"SUCCESS_MESSAGE": "Portal created successfully",
|
||||
|
||||
@@ -411,8 +411,8 @@
|
||||
"DISMISS": "Dismiss"
|
||||
},
|
||||
"COVERAGE_BANNER": {
|
||||
"TEXT": "{count} FAQs are pending review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
|
||||
"ACTION": "Review FAQs",
|
||||
"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",
|
||||
"DISMISS": "Dismiss"
|
||||
},
|
||||
"RANGES": {
|
||||
@@ -457,7 +457,7 @@
|
||||
"TITLE": "Knowledge coverage",
|
||||
"COVERAGE": "{pct}% approved",
|
||||
"APPROVED": "Approved FAQs",
|
||||
"PENDING": "Pending FAQs",
|
||||
"SUGGESTIONS": "FAQ suggestions",
|
||||
"DOCUMENTS": "Documents"
|
||||
},
|
||||
"LINKS": {
|
||||
@@ -1086,7 +1086,6 @@
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"PENDING_FAQS": "Pending FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
@@ -1095,12 +1094,10 @@
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"SEARCH_PLACEHOLDER": "Search FAQs...",
|
||||
"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."
|
||||
"ERRORS": {
|
||||
"LOAD": "There was an error loading FAQs. Please try again."
|
||||
},
|
||||
"BULK_DELETE_BUTTON": "Delete",
|
||||
"BULK_DELETE": {
|
||||
"TITLE": "Delete FAQs?",
|
||||
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
|
||||
@@ -1115,20 +1112,9 @@
|
||||
"SUCCESS_MESSAGE": "FAQ deleted successfully",
|
||||
"ERROR_MESSAGE": "There was an error deleting the FAQ, please try again."
|
||||
},
|
||||
"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"
|
||||
"SUGGESTIONS_BANNER": {
|
||||
"TITLE": "Captain grouped recurring customer questions into FAQ suggestions.",
|
||||
"ACTION": "Review suggestions"
|
||||
},
|
||||
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
|
||||
"CREATE": {
|
||||
@@ -1161,7 +1147,6 @@
|
||||
},
|
||||
"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": {
|
||||
@@ -1170,6 +1155,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -87,8 +87,8 @@ const inboxName = computed(() => props.inbox?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
if (!inbox.value) return null;
|
||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ const inboxName = computed(() => inbox.value?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
if (!inbox.value) return null;
|
||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
});
|
||||
|
||||
const fileAttachments = computed(() => {
|
||||
|
||||
@@ -129,22 +129,22 @@ onMounted(async () => {
|
||||
v-else
|
||||
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
|
||||
>
|
||||
<header class="shrink-0">
|
||||
<div class="w-full px-6 pt-6">
|
||||
<header class="px-6 pt-6 pb-4 shrink-0">
|
||||
<div class="w-full">
|
||||
<h1 class="text-xl font-medium text-n-slate-12">
|
||||
{{ t('CALLS_PAGE.HEADER') }}
|
||||
</h1>
|
||||
<CallsFilterBar
|
||||
v-model:activity="activity"
|
||||
v-model:assignee-id="assigneeId"
|
||||
v-model:inbox-id="inboxId"
|
||||
class="mt-5"
|
||||
:total-count="isFetching ? null : meta.count"
|
||||
:agents="agents"
|
||||
:inboxes="voiceInboxes"
|
||||
:show-assignee="isAdmin"
|
||||
/>
|
||||
</div>
|
||||
<CallsFilterBar
|
||||
v-model:activity="activity"
|
||||
v-model:assignee-id="assigneeId"
|
||||
v-model:inbox-id="inboxId"
|
||||
class="mt-5 pb-4 border-b border-n-weak mx-6"
|
||||
:total-count="isFetching ? null : meta.count"
|
||||
:agents="agents"
|
||||
:inboxes="voiceInboxes"
|
||||
:show-assignee="isAdmin"
|
||||
/>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
<div class="w-full">
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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';
|
||||
@@ -15,7 +19,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 ResponsesPendingIndex from './responses/Pending.vue';
|
||||
import FaqSuggestionsIndex from './responses/FaqSuggestions.vue';
|
||||
import CustomToolsIndex from './tools/Index.vue';
|
||||
|
||||
const meta = {
|
||||
@@ -24,6 +28,11 @@ 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,
|
||||
@@ -79,11 +88,21 @@ 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'),
|
||||
component: ResponsesPendingIndex,
|
||||
name: 'captain_assistants_responses_pending',
|
||||
meta,
|
||||
redirect: to => ({
|
||||
name: 'captain_assistants_faq_suggestions',
|
||||
params: to.params,
|
||||
query: to.query,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
<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,11 +1,14 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { computed, onUnmounted, ref, nextTick, watch } 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';
|
||||
@@ -41,7 +44,7 @@ const createDialog = ref(null);
|
||||
|
||||
const selectedAssistantId = computed(() => Number(route.params.assistantId));
|
||||
|
||||
const pendingCount = useMapGetter('captainResponses/getPendingCount');
|
||||
const suggestionCount = useMapGetter('captainFaqSuggestions/getOpenCount');
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDialog.value.dialogRef.open();
|
||||
@@ -95,8 +98,10 @@ const updateURLWithFilters = (page, search) => {
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'approved' };
|
||||
const { run: runListRequest, abort: abortListRequest } = useAbortableRequest();
|
||||
|
||||
const fetchResponses = async (page = 1) => {
|
||||
const filterParams = { page };
|
||||
|
||||
if (selectedAssistantId.value) {
|
||||
filterParams.assistantId = selectedAssistantId.value;
|
||||
@@ -108,7 +113,24 @@ const fetchResponses = (page = 1) => {
|
||||
// Update URL with current filters
|
||||
updateURLWithFilters(page, searchQuery.value);
|
||||
|
||||
store.dispatch('captainResponses/get', filterParams);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Bulk action
|
||||
@@ -181,24 +203,47 @@ const debouncedSearch = debounce(async () => {
|
||||
fetchResponses(1);
|
||||
}, 500);
|
||||
|
||||
const handleSearchInput = () => {
|
||||
abortListRequest();
|
||||
debouncedSearch();
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
if (route.query.search) {
|
||||
searchQuery.value = route.query.search;
|
||||
}
|
||||
searchQuery.value = route.query.search || '';
|
||||
const pageFromURL = parseInt(route.query.page, 10) || 1;
|
||||
fetchResponses(pageFromURL);
|
||||
};
|
||||
|
||||
const navigateToPendingFAQs = () => {
|
||||
router.push({ name: 'captain_assistants_responses_pending' });
|
||||
const navigateToFaqSuggestions = () => {
|
||||
router.push({
|
||||
name: 'captain_assistants_faq_suggestions',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: selectedAssistantId.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
store.dispatch(
|
||||
'captainResponses/fetchPendingCount',
|
||||
selectedAssistantId.value
|
||||
);
|
||||
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);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -240,7 +285,7 @@ onMounted(() => {
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="debouncedSearch"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -271,13 +316,13 @@ onMounted(() => {
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
<Banner
|
||||
v-if="pendingCount > 0"
|
||||
v-if="suggestionCount > 0"
|
||||
color="blue"
|
||||
class="mb-4 -mt-3"
|
||||
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
|
||||
@action="navigateToPendingFAQs"
|
||||
:action-label="$t('CAPTAIN.RESPONSES.SUGGESTIONS_BANNER.ACTION')"
|
||||
@action="navigateToFaqSuggestions"
|
||||
>
|
||||
{{ $t('CAPTAIN.RESPONSES.PENDING_BANNER.TITLE') }}
|
||||
{{ $t('CAPTAIN.RESPONSES.SUGGESTIONS_BANNER.TITLE') }}
|
||||
</Banner>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import 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>
|
||||
+7
-9
@@ -79,15 +79,13 @@ const breadcrumbItems = computed(() => {
|
||||
});
|
||||
|
||||
const buildInboxList = allInboxes =>
|
||||
allInboxes?.map(
|
||||
({ name, id, email, phoneNumber, channelType, medium, voiceEnabled }) => ({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
|
||||
})
|
||||
) || [];
|
||||
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
icon: getInboxIconByType(channelType, medium, 'line'),
|
||||
})) || [];
|
||||
|
||||
const policyInboxes = computed(() =>
|
||||
buildInboxList(selectedPolicy.value?.inboxes)
|
||||
|
||||
+7
-17
@@ -64,23 +64,13 @@ const allInboxes = computed(
|
||||
inboxes.value
|
||||
?.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(
|
||||
({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
channelType,
|
||||
medium,
|
||||
voiceEnabled,
|
||||
}) => ({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
|
||||
})
|
||||
) || []
|
||||
.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
icon: getInboxIconByType(channelType, medium, 'line'),
|
||||
})) || []
|
||||
);
|
||||
|
||||
const formData = computed(() => ({
|
||||
|
||||
+1
-2
@@ -29,8 +29,7 @@ const inboxIcon = computed(() => {
|
||||
return getInboxIconByType(
|
||||
props.inbox.channelType,
|
||||
props.inbox.medium,
|
||||
'line',
|
||||
props.inbox.voiceEnabled
|
||||
'line'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -281,12 +281,8 @@ export default {
|
||||
return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
|
||||
},
|
||||
inboxIcon() {
|
||||
const {
|
||||
medium,
|
||||
channel_type: type,
|
||||
voice_enabled: voiceEnabled,
|
||||
} = this.inbox;
|
||||
return getInboxIconByType(type, medium, 'line', voiceEnabled);
|
||||
const { medium, channel_type: type } = this.inbox;
|
||||
return getInboxIconByType(type, medium, 'line');
|
||||
},
|
||||
bannerMaxWidth() {
|
||||
const narrowTabs = ['collaborators', 'bot-configuration'];
|
||||
|
||||
@@ -48,20 +48,6 @@ 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',
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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,58 +1,22 @@
|
||||
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,6 +54,7 @@ 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';
|
||||
@@ -120,6 +121,7 @@ export default createStore({
|
||||
captainAssistants,
|
||||
captainDocuments,
|
||||
captainResponses,
|
||||
captainFaqSuggestions,
|
||||
captainInboxes,
|
||||
captainBulkActions,
|
||||
copilotThreads,
|
||||
|
||||
@@ -33,7 +33,6 @@ const state = {
|
||||
allFetched: false,
|
||||
isFetching: false,
|
||||
isSwitching: false,
|
||||
isFetchingSSLStatus: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -43,11 +43,6 @@ module PortalConfigSchema
|
||||
'popular_content' => {
|
||||
'type' => %w[object null],
|
||||
'additionalProperties' => POPULAR_CONTENT_SCHEMA
|
||||
},
|
||||
# Analytics ids keyed by provider, e.g. { "ga4" => "G-XXXX" }.
|
||||
'analytics' => {
|
||||
'type' => %w[object null],
|
||||
'additionalProperties' => { 'type' => %w[string null] }
|
||||
}
|
||||
},
|
||||
'required' => [],
|
||||
|
||||
+1
-30
@@ -46,7 +46,6 @@ class Portal < ApplicationRecord
|
||||
validates :color, format: { with: /\A#(?:\h{3}|\h{6})\z/ }, allow_blank: true
|
||||
before_validation :normalize_config
|
||||
validate :validate_config
|
||||
validate :validate_analytics
|
||||
validates_with JsonSchemaValidator,
|
||||
schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
|
||||
attribute_resolver: ->(record) { record.config }
|
||||
@@ -55,17 +54,7 @@ class Portal < ApplicationRecord
|
||||
|
||||
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
|
||||
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations
|
||||
popular_content analytics].freeze
|
||||
|
||||
# Analytics providers and their accepted id formats. Add a provider here and its
|
||||
# snippet in layouts/_portal_analytics.html.erb.
|
||||
ANALYTICS_PROVIDERS = {
|
||||
'ga4' => /\AG-[A-Z0-9]+\z/,
|
||||
'gtm' => /\AGTM-[A-Z0-9]+\z/,
|
||||
'clarity' => /\A[a-z0-9]+\z/,
|
||||
'hotjar' => /\A\d+\z/,
|
||||
'meta_pixel' => /\A\d+\z/
|
||||
}.freeze
|
||||
popular_content].freeze
|
||||
|
||||
# Max number of recommended categories/articles shown per locale.
|
||||
POPULAR_CATEGORY_LIMIT = 3
|
||||
@@ -143,11 +132,6 @@ class Portal < ApplicationRecord
|
||||
config_value('social_profiles') || {}
|
||||
end
|
||||
|
||||
# Valid analytics ids keyed by provider; drops unknown providers and blank ids.
|
||||
def analytics
|
||||
(config_value('analytics') || {}).select { |provider, id| ANALYTICS_PROVIDERS.key?(provider) && id.present? }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_config
|
||||
@@ -163,19 +147,6 @@ class Portal < ApplicationRecord
|
||||
errors.add(:config, 'default locale cannot be drafted.') if draft_locale?(default_locale)
|
||||
end
|
||||
|
||||
def validate_analytics
|
||||
(config_value('analytics') || {}).each do |provider, id|
|
||||
next if id.blank?
|
||||
|
||||
format = ANALYTICS_PROVIDERS[provider]
|
||||
if format.nil?
|
||||
errors.add(:config, "analytics provider #{provider} is not supported")
|
||||
elsif !id.match?(format)
|
||||
errors.add(:config, "analytics id for #{provider} is invalid")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_locale_codes(locale_codes)
|
||||
Array(locale_codes).filter_map(&:presence).uniq
|
||||
end
|
||||
|
||||
@@ -20,7 +20,6 @@ json.config do
|
||||
json.social_profiles portal.social_profiles
|
||||
json.locale_translations portal.config['locale_translations'] || {}
|
||||
json.popular_content portal.config['popular_content'] || {}
|
||||
json.analytics portal.config['analytics'] || {}
|
||||
end
|
||||
|
||||
if portal.channel_web_widget
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<%# Analytics snippets for the portal's configured ids (validated on save). %>
|
||||
<%# The raw output tag emits to_json so JS strings are not HTML-escaped. %>
|
||||
<% analytics = @portal.analytics %>
|
||||
<% nonce = request.content_security_policy_nonce %>
|
||||
|
||||
<% if analytics['ga4'].present? %>
|
||||
<!-- Google Analytics (GA4) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=<%= analytics['ga4'] %>" nonce="<%= nonce %>"></script>
|
||||
<script nonce="<%= nonce %>">
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', <%== analytics['ga4'].to_json %>);
|
||||
</script>
|
||||
<% end %>
|
||||
|
||||
<% if analytics['gtm'].present? %>
|
||||
<!-- Google Tag Manager -->
|
||||
<script nonce="<%= nonce %>">(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer',<%== analytics['gtm'].to_json %>);</script>
|
||||
<% end %>
|
||||
|
||||
<% if analytics['clarity'].present? %>
|
||||
<!-- Microsoft Clarity -->
|
||||
<script nonce="<%= nonce %>">
|
||||
(function(c,l,a,r,i,t,y){
|
||||
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
|
||||
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||||
})(window, document, "clarity", "script", <%== analytics['clarity'].to_json %>);
|
||||
</script>
|
||||
<% end %>
|
||||
|
||||
<% if analytics['hotjar'].present? %>
|
||||
<!-- Hotjar -->
|
||||
<script nonce="<%= nonce %>">
|
||||
(function(h,o,t,j,a,r){
|
||||
h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};
|
||||
h._hjSettings={hjid:<%== analytics['hotjar'].to_json %>,hjsv:6};
|
||||
a=o.getElementsByTagName('head')[0];
|
||||
r=o.createElement('script');r.async=1;
|
||||
r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv;
|
||||
a.appendChild(r);
|
||||
})(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
|
||||
</script>
|
||||
<% end %>
|
||||
|
||||
<% if analytics['meta_pixel'].present? %>
|
||||
<!-- Meta Pixel -->
|
||||
<script nonce="<%= nonce %>">
|
||||
!function(f,b,e,v,n,t,s)
|
||||
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
||||
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
||||
n.queue=[];t=b.createElement(e);t.async=!0;
|
||||
t.src=v;s=b.getElementsByTagName(e)[0];
|
||||
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
||||
'https://connect.facebook.net/en_US/fbevents.js');
|
||||
fbq('init', <%== analytics['meta_pixel'].to_json %>);
|
||||
fbq('track', 'PageView');
|
||||
</script>
|
||||
<% end %>
|
||||
@@ -1,7 +0,0 @@
|
||||
<%# GTM noscript fallback; must sit right after <body> (invalid in <head>). %>
|
||||
<% if @portal.analytics['gtm'].present? %>
|
||||
<!-- Google Tag Manager (noscript) -->
|
||||
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=<%= @portal.analytics['gtm'] %>"
|
||||
title="Google Tag Manager" height="0" width="0"
|
||||
style="display:none;visibility:hidden"></iframe></noscript>
|
||||
<% end %>
|
||||
@@ -23,8 +23,6 @@
|
||||
<link rel="icon" href="<%= url_for(@portal.logo) %>">
|
||||
<% end %>
|
||||
|
||||
<%= render 'layouts/portal_analytics' %>
|
||||
|
||||
<% unless @theme_from_params.blank? %>
|
||||
<%# this adds the theme from params, ensuring that there a localstorage value set %>
|
||||
<%# this will further trigger the next script to ensure color mode is toggled without a FOUC %>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<%= render 'layouts/portal_head' %>
|
||||
</head>
|
||||
<body class="font-inter bg-white dark:bg-slate-900">
|
||||
<%= render 'layouts/portal_analytics_body' %>
|
||||
<div id="portal" class="antialiased">
|
||||
<main class="flex flex-col min-h-screen main-content" role="main">
|
||||
<%= render 'public/api/v1/portals/documentation_layout/topbar',
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<%= render 'layouts/portal_head' %>
|
||||
</head>
|
||||
<body class="font-default bg-white dark:bg-slate-900">
|
||||
<%= render 'layouts/portal_analytics_body' %>
|
||||
<div id="portal" class="antialiased">
|
||||
<main class="flex flex-col min-h-screen main-content" role="main">
|
||||
<%= yield %>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<%= render 'layouts/portal_head' %>
|
||||
</head>
|
||||
<body class="font-default bg-white dark:bg-slate-900">
|
||||
<%= render 'layouts/portal_analytics_body' %>
|
||||
<div id="portal" class="antialiased">
|
||||
<main class="flex flex-col min-h-screen main-content" role="main">
|
||||
<%= render 'public/api/v1/portals/header', portal: @portal %>
|
||||
|
||||
@@ -143,6 +143,20 @@ 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,6 +626,7 @@ 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'
|
||||
|
||||
@@ -79,6 +79,10 @@ 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,7 @@
|
||||
class PurgePendingCaptainAssistantResponses < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
execute('DELETE FROM captain_assistant_responses WHERE status = 0')
|
||||
end
|
||||
|
||||
def down; end
|
||||
end
|
||||
+1
-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_13_184351) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_14_123000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
@@ -23,10 +23,11 @@ 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)
|
||||
def initialize(assistant, range = Captain::AssistantStatsWindow::DEFAULT_RANGE, timezone_offset = nil, suggestions_scope: nil)
|
||||
@assistant = assistant
|
||||
@account = assistant.account
|
||||
@window = Captain::AssistantStatsWindow.new(range, timezone_offset)
|
||||
@suggestions_scope = suggestions_scope || assistant.faq_suggestions
|
||||
end
|
||||
|
||||
def metrics
|
||||
@@ -37,18 +38,18 @@ class Captain::AssistantStatsBuilder
|
||||
build_metrics(current, previous)
|
||||
end
|
||||
|
||||
# Approved/pending FAQ counts and the document total in a single round trip.
|
||||
# Approved FAQ, open suggestion, and document counts in a single round trip.
|
||||
def faq_stats
|
||||
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
|
||||
approved, suggestions, 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("(#{open_suggestion_count_sql})"),
|
||||
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
|
||||
)
|
||||
total = approved + pending
|
||||
total = approved + suggestions
|
||||
|
||||
{
|
||||
approved: approved,
|
||||
pending: pending,
|
||||
suggestions: suggestions,
|
||||
documents: documents,
|
||||
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
|
||||
}
|
||||
@@ -56,7 +57,7 @@ class Captain::AssistantStatsBuilder
|
||||
|
||||
private
|
||||
|
||||
attr_reader :window
|
||||
attr_reader :window, :suggestions_scope
|
||||
|
||||
def current_range
|
||||
window.current
|
||||
@@ -199,6 +200,10 @@ class Captain::AssistantStatsBuilder
|
||||
rate(reopened, resolved_count)
|
||||
end
|
||||
|
||||
def open_suggestion_count_sql
|
||||
suggestions_scope.where(assistant_id: assistant.id).open.reorder(nil).select('COUNT(*)').to_sql
|
||||
end
|
||||
|
||||
def rate(numerator, denominator)
|
||||
return 0 if denominator.zero?
|
||||
|
||||
|
||||
+2
-5
@@ -43,8 +43,6 @@ 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(
|
||||
@@ -73,15 +71,14 @@ class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accoun
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id, :assistant_id, :page, :document_id, :account_id, :status, :search)
|
||||
params.permit(:id, :assistant_id, :page, :document_id, :account_id, :search)
|
||||
end
|
||||
|
||||
def response_params
|
||||
params.require(:assistant_response).permit(
|
||||
:question,
|
||||
:answer,
|
||||
:assistant_id,
|
||||
:status
|
||||
:assistant_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -47,7 +47,12 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def faq_stats
|
||||
render json: Captain::AssistantStatsBuilder.new(@assistant).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
|
||||
|
||||
@@ -39,14 +39,10 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
|
||||
responses = Current.account.captain_assistant_responses.where(id: params[:ids])
|
||||
return unless responses.exists?
|
||||
|
||||
case params[:fields][:status]
|
||||
when 'approve'
|
||||
responses.pending.update(status: 'approved')
|
||||
responses
|
||||
when 'delete'
|
||||
responses.destroy_all
|
||||
[]
|
||||
end
|
||||
return render json: { success: false }, status: :unprocessable_content unless params[:fields][:status] == 'delete'
|
||||
|
||||
responses.destroy_all
|
||||
[]
|
||||
end
|
||||
|
||||
def handle_documents
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
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
|
||||
@@ -62,7 +62,7 @@ class CallFinder
|
||||
end
|
||||
|
||||
def paginated_calls
|
||||
@calls.includes(:contact, :conversation, :accepted_by_agent, inbox: :channel)
|
||||
@calls.includes(:contact, :inbox, :conversation, :accepted_by_agent)
|
||||
.order(created_at: :desc)
|
||||
.page(@params[:page] || 1)
|
||||
.per(RESULTS_PER_PAGE)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
@@ -0,0 +1,30 @@
|
||||
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::ConversationFaqService.new(assistant, conversation).generate_and_deduplicate if assistant.config['feature_faq'].present?
|
||||
Captain::Llm::ConversationFaqJob.perform_later(conversation, assistant) 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: { pending: 0, approved: 1 }
|
||||
enum status: { approved: 1 }
|
||||
|
||||
def self.search(query, account_id: nil)
|
||||
embedding = Captain::Llm::EmbeddingService.new(account_id: account_id).get_embedding(query)
|
||||
|
||||
@@ -35,6 +35,14 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def approve?
|
||||
update?
|
||||
end
|
||||
|
||||
def dismiss?
|
||||
update?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
@@ -0,0 +1,66 @@
|
||||
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
|
||||
@@ -0,0 +1,75 @@
|
||||
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,167 +1,208 @@
|
||||
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 = conversation_faq_content
|
||||
@content = Captain::Llm::ConversationFaqContentService.new(assistant, conversation).generate
|
||||
@embedding_service = Captain::Llm::EmbeddingService.new(account_id: conversation.account_id)
|
||||
end
|
||||
|
||||
# Generates and deduplicates FAQs from conversation content
|
||||
# Skips processing if there was no human interaction
|
||||
def generate_and_deduplicate
|
||||
def generate_suggestions
|
||||
return [] if no_human_interaction?
|
||||
|
||||
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?
|
||||
generate.map { |faq| route_candidate(faq) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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
|
||||
attr_reader :content, :conversation, :assistant, :embedding_service
|
||||
|
||||
def no_human_interaction?
|
||||
conversation.first_reply_created_at.nil?
|
||||
end
|
||||
|
||||
def find_and_separate_duplicates(faqs)
|
||||
duplicate_faqs = []
|
||||
unique_faqs = []
|
||||
def route_candidate(faq)
|
||||
embedding = embedding_service.get_embedding(candidate_text(faq))
|
||||
|
||||
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)
|
||||
return discard_observation(faq) if matching_record(approved_faqs, faq, embedding)
|
||||
return discard_observation(faq) if matching_record(dismissed_suggestions_for_language, faq, embedding)
|
||||
|
||||
if similar_faqs.any?
|
||||
duplicate_faqs << { faq: faq, similar_faqs: similar_faqs }
|
||||
else
|
||||
unique_faqs << faq
|
||||
end
|
||||
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)
|
||||
end
|
||||
|
||||
[duplicate_faqs, unique_faqs]
|
||||
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
|
||||
end
|
||||
|
||||
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
|
||||
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 save_new_faqs(faqs)
|
||||
faqs.map do |faq|
|
||||
assistant.responses.create!(
|
||||
question: faq['question'],
|
||||
answer: faq['answer'],
|
||||
status: 'pending',
|
||||
documentable: conversation
|
||||
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
|
||||
)
|
||||
suggestion.update!(source_count: suggestion.source_count + 1)
|
||||
observation
|
||||
end
|
||||
end
|
||||
|
||||
def log_duplicate_faqs(duplicate_faqs)
|
||||
return if duplicate_faqs.empty?
|
||||
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
|
||||
|
||||
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
|
||||
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')}"
|
||||
end
|
||||
|
||||
def generate
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
response = instrument_llm_call(generation_instrumentation_params) do
|
||||
chat
|
||||
.with_params(response_format: { type: 'json_object' })
|
||||
.with_instructions(system_prompt)
|
||||
.ask(@content)
|
||||
.ask(content)
|
||||
end
|
||||
parse_response(response.content)
|
||||
parse_generation_response(response.content)
|
||||
rescue RubyLLM::Error => e
|
||||
Rails.logger.error "LLM API Error: #{e.message}"
|
||||
[]
|
||||
end
|
||||
|
||||
def instrumentation_params
|
||||
def generation_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 }
|
||||
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 }
|
||||
}
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
account_language = @conversation.account.locale_english_name
|
||||
Captain::Llm::SystemPromptsService.conversation_faq_generator(account_language)
|
||||
Captain::Llm::ConversationFaqPromptsService.generator(language_name(faq_language))
|
||||
end
|
||||
|
||||
def parse_response(response)
|
||||
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)
|
||||
return [] if response.nil?
|
||||
|
||||
JSON.parse(sanitize_json_response(response)).fetch('faqs', [])
|
||||
|
||||
@@ -51,62 +51,6 @@ 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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/captain/assistant_response', formats: [:json], resource: @response
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion
|
||||
@@ -0,0 +1,10 @@
|
||||
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
|
||||
@@ -0,0 +1,6 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/captain/faq_suggestion', formats: [:json], resource: @suggestion
|
||||
@@ -19,8 +19,6 @@ end
|
||||
json.inbox do
|
||||
json.id call.inbox_id
|
||||
json.name call.inbox.name
|
||||
json.channel_type call.inbox.channel_type
|
||||
json.medium call.inbox.channel.try(:medium)
|
||||
end
|
||||
|
||||
if call.accepted_by_agent
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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
|
||||
@@ -0,0 +1,12 @@
|
||||
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,6 +89,7 @@ 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
|
||||
|
||||
@@ -175,21 +175,11 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
|
||||
'layout' => 'classic',
|
||||
'social_profiles' => {},
|
||||
'locale_translations' => {},
|
||||
'popular_content' => {},
|
||||
'analytics' => {}
|
||||
'popular_content' => {}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'allows administrators to set analytics config' do
|
||||
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
|
||||
params: { portal: { config: { analytics: { ga4: 'G-ADMIN12345' } } } },
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(portal.reload.config['analytics']).to eq('ga4' => 'G-ADMIN12345')
|
||||
end
|
||||
|
||||
it 'preserves drafted locales when draft_locales is omitted' do
|
||||
portal.update!(config: { allowed_locales: %w[en es fr], draft_locales: ['es'], default_locale: 'en' })
|
||||
|
||||
|
||||
@@ -232,22 +232,26 @@ RSpec.describe Captain::AssistantStatsBuilder do
|
||||
describe '#faq_stats' do
|
||||
before do
|
||||
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
|
||||
create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
|
||||
assistant.faq_suggestions.create!(
|
||||
question: 'How do I enable the feature?',
|
||||
answer: 'Turn it on in settings.'
|
||||
)
|
||||
create_list(:captain_document, 2, assistant: assistant, account: account)
|
||||
end
|
||||
|
||||
it 'returns approved, pending, document counts and coverage' do
|
||||
knowledge = described_class.new(assistant).faq_stats
|
||||
it 'returns approved FAQ, open suggestion, document counts and coverage' do
|
||||
stats = described_class.new(assistant).faq_stats
|
||||
|
||||
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
|
||||
expect(stats).to eq(approved: 3, suggestions: 1, documents: 2, coverage: 75)
|
||||
end
|
||||
|
||||
it 'reports zero coverage when there are no responses' do
|
||||
it 'reports zero coverage when there are no FAQs or suggestions' do
|
||||
Captain::AssistantResponse.where(assistant: assistant).delete_all
|
||||
Captain::FaqSuggestion.where(assistant: assistant).delete_all
|
||||
|
||||
knowledge = described_class.new(assistant).faq_stats
|
||||
stats = described_class.new(assistant).faq_stats
|
||||
|
||||
expect(knowledge[:coverage]).to eq(0)
|
||||
expect(stats).to eq(approved: 0, suggestions: 0, documents: 2, coverage: 0)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ RSpec.describe 'Calls API', type: :request do
|
||||
item = body['payload'].find { |c| c['id'] == agent_call.id }
|
||||
expect(item['transcript']).to eq('hello world')
|
||||
expect(item['contact']['phone_number']).to eq(contact.phone_number)
|
||||
expect(item['inbox']).to include('id' => inbox.id, 'name' => inbox.name, 'channel_type' => inbox.channel_type)
|
||||
end
|
||||
|
||||
it 'scopes the list to calls the agent accepted' do
|
||||
|
||||
+13
@@ -177,6 +177,19 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
|
||||
|
||||
expect(json_response[:question]).to eq('Test question?')
|
||||
expect(json_response[:answer]).to eq('Test answer')
|
||||
expect(json_response[:status]).to eq('approved')
|
||||
end
|
||||
|
||||
it 'does not accept the removed pending status' do
|
||||
params = valid_params.deep_merge(assistant_response: { status: 'pending' })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistant_responses",
|
||||
params: params,
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Captain::AssistantResponse.last).to be_approved
|
||||
end
|
||||
|
||||
context 'with invalid params' do
|
||||
|
||||
@@ -252,6 +252,61 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}/faq_stats' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'returns approved FAQ, open suggestion, document counts and coverage' do
|
||||
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
|
||||
assistant.faq_suggestions.create!(question: 'How do I enable the feature?', answer: 'Turn it on in settings.')
|
||||
create_list(:captain_document, 2, assistant: assistant, account: account)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/faq_stats",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response).to eq(approved: 3, suggestions: 1, documents: 2, coverage: 75)
|
||||
end
|
||||
|
||||
it 'returns zero coverage when there are no FAQs or suggestions' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/faq_stats",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response).to include(approved: 0, suggestions: 0, coverage: 0)
|
||||
end
|
||||
|
||||
it 'counts only suggestions backed by conversations the agent can access' do
|
||||
accessible_inbox = create(:inbox, account: account)
|
||||
hidden_inbox = create(:inbox, account: account)
|
||||
create(:inbox_member, user: agent, inbox: accessible_inbox)
|
||||
create(:captain_assistant_response, assistant: assistant, account: account, status: :approved)
|
||||
|
||||
accessible_suggestion = assistant.faq_suggestions.create!(question: 'Visible question', answer: 'Visible answer')
|
||||
accessible_suggestion.observations.create!(
|
||||
conversation: create(:conversation, account: account, inbox: accessible_inbox),
|
||||
generated_question: accessible_suggestion.question,
|
||||
generated_answer: accessible_suggestion.answer,
|
||||
language: accessible_suggestion.language
|
||||
)
|
||||
hidden_suggestion = assistant.faq_suggestions.create!(question: 'Hidden question', answer: 'Hidden answer')
|
||||
hidden_suggestion.observations.create!(
|
||||
conversation: create(:conversation, account: account, inbox: hidden_inbox),
|
||||
generated_question: hidden_suggestion.question,
|
||||
generated_answer: hidden_suggestion.answer,
|
||||
language: hidden_suggestion.language
|
||||
)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/faq_stats",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response).to include(approved: 1, suggestions: 1, coverage: 50)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}/summary' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:alice) { create(:user, account: account, role: :administrator, name: 'Alice Adams') }
|
||||
|
||||
+21
-40
@@ -5,13 +5,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let!(:pending_responses) do
|
||||
let!(:responses) do
|
||||
create_list(
|
||||
:captain_assistant_response,
|
||||
2,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: 'pending'
|
||||
account: account
|
||||
)
|
||||
end
|
||||
let!(:documents) do
|
||||
@@ -29,29 +28,20 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/captain/bulk_actions' do
|
||||
context 'when approving responses' do
|
||||
let(:valid_params) do
|
||||
{
|
||||
type: 'AssistantResponse',
|
||||
ids: pending_responses.map(&:id),
|
||||
fields: { status: 'approve' }
|
||||
}
|
||||
end
|
||||
|
||||
it 'approves the responses and returns the updated records' do
|
||||
context 'when using the removed bulk approval action' do
|
||||
it 'returns unprocessable content without changing responses' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: valid_params,
|
||||
params: {
|
||||
type: 'AssistantResponse',
|
||||
ids: responses.map(&:id),
|
||||
fields: { status: 'approve' }
|
||||
},
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response.length).to eq(2)
|
||||
|
||||
# Verify responses were approved
|
||||
pending_responses.each do |response|
|
||||
expect(response.reload.status).to eq('approved')
|
||||
end
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(json_response[:success]).to be(false)
|
||||
expect(responses.map(&:reload)).to all(be_approved)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -59,7 +49,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
let(:delete_params) do
|
||||
{
|
||||
type: 'AssistantResponse',
|
||||
ids: pending_responses.map(&:id),
|
||||
ids: responses.map(&:id),
|
||||
fields: { status: 'delete' }
|
||||
}
|
||||
end
|
||||
@@ -76,7 +66,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
expect(json_response).to eq([])
|
||||
|
||||
# Verify responses were deleted
|
||||
pending_responses.each do |response|
|
||||
responses.each do |response|
|
||||
expect { response.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
@@ -86,8 +76,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
let(:invalid_params) do
|
||||
{
|
||||
type: 'InvalidType',
|
||||
ids: pending_responses.map(&:id),
|
||||
fields: { status: 'approve' }
|
||||
ids: responses.map(&:id),
|
||||
fields: { status: 'delete' }
|
||||
}
|
||||
end
|
||||
|
||||
@@ -100,10 +90,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response[:success]).to be(false)
|
||||
|
||||
# Verify no changes were made
|
||||
pending_responses.each do |response|
|
||||
expect(response.reload.status).to eq('pending')
|
||||
end
|
||||
expect(responses.map(&:reload)).to all(be_approved)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -245,7 +232,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
let(:missing_params) do
|
||||
{
|
||||
type: 'AssistantResponse',
|
||||
fields: { status: 'approve' }
|
||||
fields: { status: 'delete' }
|
||||
}
|
||||
end
|
||||
|
||||
@@ -258,10 +245,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response[:success]).to be(false)
|
||||
|
||||
# Verify no changes were made
|
||||
pending_responses.each do |response|
|
||||
expect(response.reload.status).to eq('pending')
|
||||
end
|
||||
expect(responses.map(&:reload)).to all(be_approved)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -270,16 +254,13 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
|
||||
|
||||
it 'returns unauthorized status' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
|
||||
params: { type: 'AssistantResponse', ids: [1], fields: { status: 'approve' } },
|
||||
params: { type: 'AssistantResponse', ids: [1], fields: { status: 'delete' } },
|
||||
headers: unauthorized_user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
|
||||
# Verify no changes were made
|
||||
pending_responses.each do |response|
|
||||
expect(response.reload.status).to eq('pending')
|
||||
end
|
||||
expect(responses.map(&:reload)).to all(be_approved)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::Captain::FaqSuggestions', type: :request do
|
||||
let(:account) { create(:account, locale: 'en') }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:suggestion) do
|
||||
assistant.faq_suggestions.create!(
|
||||
question: 'How do I enable the feature?',
|
||||
answer: 'Turn it on in settings.',
|
||||
source_count: 1
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
suggestion.observations.create!(
|
||||
conversation: conversation,
|
||||
generated_question: suggestion.question,
|
||||
generated_answer: suggestion.answer,
|
||||
language: suggestion.language
|
||||
)
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/captain/faq_suggestions' do
|
||||
it 'returns suggestions and their count to an administrator' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/faq_suggestions",
|
||||
params: { assistant_id: assistant.id },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload']).to contain_exactly(
|
||||
include('id' => suggestion.id, 'question' => suggestion.question, 'status' => 'open')
|
||||
)
|
||||
expect(response.parsed_body['meta']).to include('total_count' => 1)
|
||||
end
|
||||
|
||||
it 'returns only suggestions backed by conversations the agent can access' do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
hidden_inbox = create(:inbox, account: account)
|
||||
hidden_conversation = create(:conversation, account: account, inbox: hidden_inbox)
|
||||
hidden_suggestion = assistant.faq_suggestions.create!(question: 'Hidden question', answer: 'Hidden answer')
|
||||
hidden_suggestion.observations.create!(
|
||||
conversation: hidden_conversation,
|
||||
generated_question: hidden_suggestion.question,
|
||||
generated_answer: hidden_suggestion.answer,
|
||||
language: hidden_suggestion.language
|
||||
)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/faq_suggestions",
|
||||
params: { assistant_id: assistant.id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['payload'].pluck('id')).to contain_exactly(suggestion.id)
|
||||
expect(response.parsed_body['meta']).to include('total_count' => 1)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/:account_id/captain/faq_suggestions/:id' do
|
||||
it 'returns the suggestion with its source conversation' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body).to include('id' => suggestion.id, 'question' => suggestion.question)
|
||||
expect(response.parsed_body['observations']).to contain_exactly(
|
||||
include('conversation' => include('id' => conversation.id, 'display_id' => conversation.display_id))
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns only source conversations the agent can access' do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
hidden_conversation = create(:conversation, account: account, inbox: create(:inbox, account: account))
|
||||
suggestion.observations.create!(
|
||||
conversation: hidden_conversation,
|
||||
generated_question: suggestion.question,
|
||||
generated_answer: suggestion.answer,
|
||||
language: suggestion.language
|
||||
)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['observations'].pluck('conversation').pluck('id')).to contain_exactly(conversation.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /api/v1/accounts/:account_id/captain/faq_suggestions/:id' do
|
||||
it 'lets an administrator edit an open suggestion' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
|
||||
params: { faq_suggestion: { question: 'Updated question' } },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['question']).to eq('Updated question')
|
||||
expect(suggestion.reload.question).to eq('Updated question')
|
||||
end
|
||||
|
||||
it 'lets an agent edit an accessible suggestion' do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
|
||||
patch "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
|
||||
params: { faq_suggestion: { question: 'Updated question' } },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(suggestion.reload.question).to eq('Updated question')
|
||||
end
|
||||
|
||||
it 'does not let an agent edit an inaccessible suggestion' do
|
||||
patch "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}",
|
||||
params: { faq_suggestion: { question: 'Updated question' } },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(suggestion.reload.question).to eq('How do I enable the feature?')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/captain/faq_suggestions/:id/approve' do
|
||||
it 'lets an administrator approve an edited suggestion as an FAQ' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve",
|
||||
params: { faq_suggestion: { answer: 'Enable it in account settings.' } },
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(assistant.responses.approved, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['answer']).to eq('Enable it in account settings.')
|
||||
expect(suggestion.reload).to be_approved
|
||||
expect(suggestion.observations.pluck(:conversation_id)).to contain_exactly(conversation.id)
|
||||
end
|
||||
|
||||
it 'lets an agent approve an accessible suggestion' do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(assistant.responses.approved, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(suggestion.reload).to be_approved
|
||||
end
|
||||
|
||||
it 'approves a suggestion written in a language other than the account locale' do
|
||||
suggestion.update!(language: 'pt')
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(assistant.responses.approved, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(suggestion.reload).to be_approved
|
||||
end
|
||||
|
||||
it 'does not let an agent approve an inaccessible suggestion' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/approve",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to change(assistant.responses, :count)
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(suggestion.reload).to be_open
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/captain/faq_suggestions/:id/dismiss' do
|
||||
it 'lets an administrator dismiss an open suggestion without creating an FAQ' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/dismiss",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
end.not_to change(assistant.responses, :count)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(suggestion.reload).to be_dismissed
|
||||
end
|
||||
|
||||
it 'lets an agent dismiss an accessible suggestion' do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/dismiss",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(suggestion.reload).to be_dismissed
|
||||
end
|
||||
|
||||
it 'does not let an agent dismiss an inaccessible suggestion' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/faq_suggestions/#{suggestion.id}/dismiss",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(suggestion.reload).to be_open
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -85,17 +85,6 @@ RSpec.describe 'Enterprise Portal API', type: :request do
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['name']).to eq('updated_portal')
|
||||
end
|
||||
|
||||
it 'ignores analytics config for knowledge_base_manage users' do
|
||||
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
|
||||
params: { portal: { name: 'updated_portal', config: { analytics: { ga4: 'G-KBMANAGER1' } } } },
|
||||
headers: agent_with_role.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(portal.reload.name).to eq('updated_portal')
|
||||
expect(portal.config['analytics']).to be_blank
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::ConversationFaqJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account, config: { feature_faq: true }) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, first_reply_created_at: Time.zone.now) }
|
||||
let(:faq_service) { instance_double(Captain::Llm::ConversationFaqService, generate_suggestions: []) }
|
||||
let(:lock_manager) { instance_double(Redis::LockManager, lock: true, unlock: true) }
|
||||
let(:lock_key) { "CAPTAIN_CONVERSATION_FAQ_LOCK::#{assistant.id}::en" }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, inbox: inbox, captain_assistant: assistant)
|
||||
conversation.update!(status: :resolved)
|
||||
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
|
||||
allow(Captain::Llm::ConversationFaqService).to receive(:new).and_return(faq_service)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'uses the assistant captured when the job was enqueued' do
|
||||
replacement_assistant = create(:captain_assistant, account: account, config: { feature_faq: true })
|
||||
inbox.captain_inbox.update!(captain_assistant: replacement_assistant)
|
||||
|
||||
expect(inbox.reload.captain_assistant).to eq(replacement_assistant)
|
||||
expect(Captain::Llm::ConversationFaqService).to receive(:new)
|
||||
.with(assistant, conversation)
|
||||
.and_return(faq_service)
|
||||
expect(faq_service).to receive(:generate_suggestions)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
|
||||
it 'locks FAQ grouping for the assistant and normalized language' do
|
||||
conversation.update!(additional_attributes: { conversation_language: 'pt-BR' })
|
||||
expected_key = "CAPTAIN_CONVERSATION_FAQ_LOCK::#{assistant.id}::pt"
|
||||
|
||||
expect(lock_manager).to receive(:lock).with(expected_key, described_class::LOCK_TIMEOUT).and_return(true)
|
||||
expect(lock_manager).to receive(:unlock).with(expected_key)
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
|
||||
context 'when another job holds the grouping lock' do
|
||||
before do
|
||||
allow(lock_manager).to receive(:lock).with(lock_key, described_class::LOCK_TIMEOUT).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not generate suggestions concurrently' do
|
||||
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
|
||||
|
||||
expect do
|
||||
described_class.new.perform(conversation, assistant)
|
||||
end.to raise_error(MutexApplicationJob::LockAcquisitionError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -29,7 +29,7 @@ describe CaptainListener do
|
||||
.to receive(:new)
|
||||
.with(assistant, conversation)
|
||||
.and_return(instance_double(Captain::Llm::ContactNotesService, generate_and_update_notes: nil))
|
||||
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
|
||||
expect(Captain::Llm::ConversationFaqJob).not_to receive(:perform_later)
|
||||
|
||||
listener.conversation_resolved(event)
|
||||
end
|
||||
@@ -42,11 +42,8 @@ describe CaptainListener do
|
||||
assistant.save!
|
||||
end
|
||||
|
||||
it 'generates and deduplicates FAQs' do
|
||||
expect(Captain::Llm::ConversationFaqService)
|
||||
.to receive(:new)
|
||||
.with(assistant, conversation)
|
||||
.and_return(instance_double(Captain::Llm::ConversationFaqService, generate_and_deduplicate: false))
|
||||
it 'enqueues FAQ suggestion generation' do
|
||||
expect(Captain::Llm::ConversationFaqJob).to receive(:perform_later).with(conversation, assistant)
|
||||
expect(Captain::Llm::ContactNotesService).not_to receive(:new)
|
||||
|
||||
listener.conversation_resolved(event)
|
||||
|
||||
@@ -80,24 +80,6 @@ RSpec.describe Captain::AssistantMigration::DraftApplier do
|
||||
end.not_to(change { assistant.responses.count })
|
||||
end
|
||||
|
||||
it 'leaves pending FAQ responses untouched' do
|
||||
pending_response = assistant.responses.create!(
|
||||
question: faq_document_candidate['question'],
|
||||
answer: faq_document_candidate['answer'],
|
||||
status: :pending
|
||||
)
|
||||
|
||||
described_class.new(assistant: assistant, draft: draft, dry_run: false).perform
|
||||
|
||||
expect(pending_response.reload).to be_pending
|
||||
expect(assistant.responses.approved).to contain_exactly(
|
||||
have_attributes(
|
||||
question: faq_document_candidate['question'],
|
||||
answer: faq_document_candidate['answer']
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
it 'rejects conflicting FAQ answers within the same draft' do
|
||||
conflicting_draft = draft.merge(
|
||||
faq_document_candidates: [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::ConversationFaqPromptsService do
|
||||
describe '.generator' do
|
||||
it 'allows a complete FAQ answer to use several related agent messages' do
|
||||
prompt = described_class.generator
|
||||
|
||||
expect(prompt).to include('message or messages that together provide a complete public answer')
|
||||
expect(prompt).to include('Combine facts only across related agent messages')
|
||||
expect(prompt).not_to include('no single human agent message')
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user