Compare commits

..
Author SHA1 Message Date
iamsivin 62a990e1fe chore: fix dialog flikkering 2026-07-23 17:49:16 +05:30
iamsivin bbd7a46618 chore: use useAbortableRequest for FAQ list fetches 2026-07-23 17:13:26 +05:30
iamsivin 8d068dbac6 chore: Use CardLayout component 2026-07-23 16:24:30 +05:30
aakashb95 be73363b6e Merge remote-tracking branch 'origin/feature/cw-7495-api' into cook/pr-15017-review
# Conflicts:
#	enterprise/app/builders/captain/assistant_stats_builder.rb
#	enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
#	spec/enterprise/builders/captain/assistant_stats_builder_spec.rb
2026-07-23 15:29:47 +05:30
aakashb95 8c28fb74ec feat(captain): allow agents to act on FAQ suggestions 2026-07-22 23:36:22 +05:30
aakashb95 2ec33da5cd fix(captain): use display IDs for FAQ source links 2026-07-22 23:35:22 +05:30
aakashb95 63d765b14b Merge remote-tracking branch 'origin/feature/cw-7495-api' into cook/pr-15017-review 2026-07-22 23:33:29 +05:30
Sivin VargheseandGitHub 3ffbaf680c Merge branch 'feature/cw-7495-api' into feature/cw-7496-frontend 2026-07-22 19:21:07 +05:30
aakashb95 41082f845f test(captain): cover FAQ review behavior 2026-07-22 11:46:15 +05:30
aakashb95 d054c19770 Merge branch 'cook/faq-comparison-api' into cook/faq-suggestion-stack 2026-07-22 11:45:39 +05:30
aakashb95 9d8af17b95 Merge branch 'cook/faq-comparison-api' into cook/faq-suggestion-stack 2026-07-22 11:42:33 +05:30
aakashb95 28fb05005d Merge remote-tracking branch 'origin/feature/cw-7495-api' into cook/faq-suggestion-stack 2026-07-22 10:40:55 +05:30
aakashb95 70a63695c3 fix(captain): fix FAQ suggestion review flows 2026-07-22 00:24:49 +05:30
aakashb95 325c306a1e Merge remote-tracking branch 'origin/feature/cw-7495-api' into codex/pr-15017-main
# Conflicts:
#	enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
2026-07-21 18:21:09 +05:30
aakashb95 c25aac7a99 fix(captain): scope overview suggestion counts 2026-07-21 18:07:02 +05:30
aakashb95 4c947f9260 Merge remote-tracking branch 'origin/feature/cw-7495-api' into codex/pr-15017-main 2026-07-21 18:05:21 +05:30
aakashb95 2485bc242d fix(captain): clean up FAQ suggestion review flow 2026-07-21 12:11:55 +05:30
Aakash BakhleandGitHub 4d22d2c18e Merge branch 'feature/cw-7495-api' into feature/cw-7496-frontend 2026-07-17 18:04:41 +05:30
aakashb95 2579e2b93b Merge remote-tracking branch 'origin/feature/cw-7495-api' into feature/cw-7496-frontend 2026-07-14 23:40:26 +05:30
aakashb95 5264a18dc8 Merge remote-tracking branch 'origin/feature/cw-7495-api' into feature/cw-7496-frontend 2026-07-14 23:18:28 +05:30
aakashb95 f04eb85175 chore(captain): purge legacy pending FAQs 2026-07-14 23:00:38 +05:30
aakashb95 637eddad34 fix(captain): use display IDs for FAQ sources 2026-07-14 23:00:38 +05:30
aakashb95 d3e1ad56ac feat(captain): add FAQ suggestion review interface 2026-07-14 23:00:38 +05:30
32 changed files with 1403 additions and 636 deletions
@@ -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,
});
}
}
@@ -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>
@@ -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>
@@ -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>
@@ -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',
@@ -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',
])
);
});
});
@@ -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>
@@ -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 });
},
@@ -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',
@@ -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",
@@ -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>
@@ -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);
}
},
}),
});
+2
View File
@@ -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,
@@ -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
View File
@@ -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?
@@ -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
@@ -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)
@@ -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
@@ -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') }
@@ -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
@@ -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: [