feat(captain): add FAQ suggestion review interface (4/4) (#15017)

Captain now groups recurring questions from resolved conversations into
FAQ suggestions and orders them by the number of source conversations.
Agents can view suggestions and open source conversations they can
access. Administrators can edit, approve, or dismiss suggestions.

The old pending FAQ flow is removed. The Captain overview and FAQ page
now use open suggestion counts and link to the same review page.
Approved FAQs remain unchanged.

## Depends on

#14979

## Closes

https://linear.app/chatwoot/issue/CW-7496/fe-and-ux

## How to test

1. Open Captain and choose an assistant with open FAQ suggestions.
2. Open FAQ suggestions from the overview or the FAQ banner. Confirm
that suggestions are ordered by conversation count.
3. Switch assistants without leaving the page. Confirm that the previous
results clear and the new assistant results load.
4. Search for suggestions and move between pages. Change the search or
page again before the first request finishes, and confirm that the
latest request controls the results and loading state.
5. Open a suggestion and review its source conversations.
6. Make the source conversation request fail. Confirm that the dialog
keeps the error visible and that Retry loads the sources.
7. Sign in as an agent. Confirm that you can read suggestions and source
conversations you can access, but cannot edit, approve, or dismiss
suggestions.
8. Sign in as an administrator. Edit and save a suggestion, approve one
suggestion, and dismiss another.
9. Confirm that the approved suggestion appears in the assistant FAQ
list.
10. Open the old pending FAQ URL and confirm that it redirects to FAQ
suggestions.

## What changed

1. Added the FAQ suggestion list, cards, search, pagination, and empty
state.
2. Added a review dialog with source conversation links, a clear error
message, and a Retry button.
3. Added edit, approve, and dismiss actions for administrators.
4. Removed the old pending FAQ status, count, page, and bulk approval
action.
5. Updated the Captain overview and FAQ banner to use open suggestion
counts and link to FAQ suggestions.
6. Made each FAQ page load data for the selected assistant and ignore
results from older requests.
7. Kept the old pending FAQ URL as a redirect so saved links continue to
work.

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
Aakash Bakhle
2026-07-24 12:53:09 +05:30
committed by GitHub
parent 7786e38df9
commit 7910eabefc
32 changed files with 1494 additions and 636 deletions

View File

@@ -0,0 +1,40 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainFaqSuggestions extends ApiClient {
constructor() {
super('captain/faq_suggestions', { accountScoped: true });
}
show(id, { signal } = {}) {
return axios.get(`${this.url}/${id}`, { signal });
}
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();

View File

@@ -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,
});
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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',

View File

@@ -0,0 +1,232 @@
import { flushPromises, shallowMount } from '@vue/test-utils';
import Button from 'dashboard/components-next/button/Button.vue';
import FaqSuggestionReviewDialog from './FaqSuggestionReviewDialog.vue';
const { dispatch, push, show, uiFlags } = vi.hoisted(() => ({
dispatch: vi.fn(),
push: vi.fn(),
show: vi.fn(),
uiFlags: {
value: {
fetchingItem: false,
updatingItem: false,
deletingItem: false,
},
},
}));
vi.mock('dashboard/composables/store', () => ({
useStore: () => ({ dispatch }),
useMapGetter: () => uiFlags,
}));
vi.mock('dashboard/api/captain/faqSuggestions', () => ({
default: { show },
}));
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();
uiFlags.value.updatingItem = false;
uiFlags.value.deletingItem = false;
});
it('keeps a visible error when source conversations fail to load', async () => {
show.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 () => {
show.mockResolvedValueOnce({
data: {
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 () => {
show.mockResolvedValueOnce({ data: { 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',
])
);
});
it('disables review actions while source conversations are loading', async () => {
let resolveRequest;
show.mockReturnValueOnce(
new Promise(resolve => {
resolveRequest = resolve;
})
);
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();
const actionButtons = wrapper
.findAllComponents(Button)
.filter(button =>
[
'CAPTAIN.FAQ_SUGGESTIONS.DISMISS',
'CAPTAIN.FAQ_SUGGESTIONS.SAVE',
'CAPTAIN.FAQ_SUGGESTIONS.APPROVE_FAQ',
].includes(button.props('label'))
);
expect(actionButtons).toHaveLength(3);
expect(
actionButtons.map(button => ({
label: button.props('label'),
disabled: button.attributes('disabled'),
}))
).toEqual([
{
label: 'CAPTAIN.FAQ_SUGGESTIONS.DISMISS',
disabled: 'true',
},
{
label: 'CAPTAIN.FAQ_SUGGESTIONS.SAVE',
disabled: 'true',
},
{
label: 'CAPTAIN.FAQ_SUGGESTIONS.APPROVE_FAQ',
disabled: 'true',
},
]);
resolveRequest({ data: { observations: [] } });
await flushPromises();
const enabledActionButtons = wrapper
.findAllComponents(Button)
.filter(button =>
[
'CAPTAIN.FAQ_SUGGESTIONS.DISMISS',
'CAPTAIN.FAQ_SUGGESTIONS.SAVE',
'CAPTAIN.FAQ_SUGGESTIONS.APPROVE_FAQ',
].includes(button.props('label'))
);
expect(
enabledActionButtons.map(button => button.attributes('disabled'))
).toEqual(['false', 'false', 'false']);
});
});

View File

@@ -0,0 +1,324 @@
<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 { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
import { dynamicTime } from 'shared/helpers/timeHelper';
import CaptainFaqSuggestionsAPI from 'dashboard/api/captain/faqSuggestions';
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 isSaving = computed(() => uiFlags.value.updatingItem);
const isDismissing = computed(() => uiFlags.value.deletingItem);
const { run: runDetailsRequest, isPending: isFetching } = useAbortableRequest();
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 {
const response = await runDetailsRequest(signal =>
CaptainFaqSuggestionsAPI.show(props.suggestion.id, { signal })
);
if (response) details.value = response.data;
} 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="isFetching || 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="isFetching || isInvalid || isSaving || isDismissing"
@click="handleSave"
/>
<Button
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.APPROVE_FAQ')"
icon="i-lucide-circle-check-big"
:is-loading="isSaving"
:disabled="isFetching || isInvalid || isSaving || isDismissing"
@click="handleApprove"
/>
</div>
</div>
</template>
</Dialog>
</template>

View File

@@ -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 });
},

View File

@@ -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',

View File

@@ -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 FAQ suggestions will appear here when they are ready to review.",
"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",

View File

@@ -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'),

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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',

View File

@@ -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);
}
},
}),
});

View File

@@ -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);
}
},
}),
});

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,