Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/api-webhook-feature-flag
This commit is contained in:
@@ -66,6 +66,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
responsesCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -112,10 +116,10 @@ const showSyncStatus = computed(() => !isPdf.value);
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
{
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
|
||||
value: 'viewRelatedQuestions',
|
||||
action: 'viewRelatedQuestions',
|
||||
icon: 'i-ph-tree-view-duotone',
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_DETAILS'),
|
||||
value: 'viewDetails',
|
||||
action: 'viewDetails',
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -143,6 +147,9 @@ const menuItems = computed(() => {
|
||||
});
|
||||
|
||||
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
|
||||
const responsesCountLabel = computed(() =>
|
||||
t('CAPTAIN.DOCUMENTS.FAQ_COUNT', { n: props.responsesCount })
|
||||
);
|
||||
|
||||
const displayLink = computed(() =>
|
||||
isPdf.value
|
||||
@@ -158,6 +165,10 @@ const handleAction = ({ action, value }) => {
|
||||
emit('action', { action, value, id: props.id });
|
||||
};
|
||||
|
||||
const handleViewDetails = () => {
|
||||
emit('action', { action: 'viewDetails', id: props.id });
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
emit('action', { action: 'sync', id: props.id });
|
||||
};
|
||||
@@ -177,9 +188,13 @@ const handleRetry = () => {
|
||||
<Checkbox v-model="modelValue" />
|
||||
</div>
|
||||
<div class="flex gap-1 justify-between w-full">
|
||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||
<button
|
||||
type="button"
|
||||
class="p-0 text-base text-left bg-transparent border-0 outline-transparent text-n-slate-12 line-clamp-1 underline-offset-2 hover:underline focus-visible:underline"
|
||||
@click="handleViewDetails"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="showMenu" class="flex gap-2 items-center">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
@@ -228,6 +243,9 @@ const handleRetry = () => {
|
||||
<Icon :icon="linkIcon" class="shrink-0" />
|
||||
<span class="truncate">{{ displayLink }}</span>
|
||||
</span>
|
||||
<span class="text-sm shrink-0 text-n-slate-11">
|
||||
{{ responsesCountLabel }}
|
||||
</span>
|
||||
<DocumentSyncStatus
|
||||
v-if="showSyncStatus"
|
||||
:status="syncStatus"
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils';
|
||||
import DocumentDetails from './DocumentDetails.vue';
|
||||
|
||||
const { dispatch, getterValues } = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
getterValues: {
|
||||
'captainResponses/getUIFlags': { value: { fetchingList: false } },
|
||||
'captainResponses/getRecords': { value: [] },
|
||||
'captainResponses/getMeta': { value: { totalCount: 26, page: 1 } },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useStore: () => ({ dispatch }),
|
||||
useMapGetter: key => getterValues[key],
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
}));
|
||||
|
||||
const captainDocument = {
|
||||
id: 42,
|
||||
name: 'FAQ source',
|
||||
external_link: 'https://example.com/docs',
|
||||
assistant: { id: 7 },
|
||||
content: 'Document content',
|
||||
pdf_document: false,
|
||||
};
|
||||
|
||||
const DialogStub = {
|
||||
name: 'Dialog',
|
||||
template: '<div><slot /></div>',
|
||||
};
|
||||
|
||||
const TabBarStub = {
|
||||
name: 'TabBar',
|
||||
template:
|
||||
'<button data-test="faq-tab" @click="$emit(\'tabChanged\', { key: \'faqs\' })" />',
|
||||
};
|
||||
|
||||
const PaginationFooterStub = {
|
||||
name: 'PaginationFooter',
|
||||
template:
|
||||
'<button data-test="next-page" @click="$emit(\'update:currentPage\', 2)" />',
|
||||
};
|
||||
|
||||
describe('DocumentDetails', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dispatch.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('requests another FAQ page when the document has more than 25 FAQs', async () => {
|
||||
const wrapper = shallowMount(DocumentDetails, {
|
||||
props: { captainDocument },
|
||||
global: {
|
||||
directives: { dompurifyHtml: {} },
|
||||
stubs: {
|
||||
Dialog: DialogStub,
|
||||
TabBar: TabBarStub,
|
||||
PaginationFooter: PaginationFooterStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('captainResponses/get', {
|
||||
page: 1,
|
||||
assistantId: 7,
|
||||
documentId: 42,
|
||||
});
|
||||
|
||||
await wrapper.get('[data-test="faq-tab"]').trigger('click');
|
||||
await wrapper.get('[data-test="next-page"]').trigger('click');
|
||||
|
||||
expect(dispatch).toHaveBeenLastCalledWith('captainResponses/get', {
|
||||
page: 2,
|
||||
assistantId: 7,
|
||||
documentId: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
import {
|
||||
isSafeHttpLink,
|
||||
formatDocumentLink,
|
||||
getDocumentDisplayPath,
|
||||
} from 'shared/helpers/documentHelper';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import ResponseCard from '../../assistant/ResponseCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
captainDocument: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
const TAB_KEYS = {
|
||||
CONTENT: 'content',
|
||||
FAQS: 'faqs',
|
||||
};
|
||||
const RESPONSES_PER_PAGE = 25;
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const dialogRef = ref(null);
|
||||
const documentDetails = computed(() => props.captainDocument);
|
||||
const showRawContent = ref(false);
|
||||
const activeTabIndex = ref(0);
|
||||
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
const meta = useMapGetter('captainResponses/getMeta');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const totalCount = computed(() => meta.value.totalCount || 0);
|
||||
const currentPage = computed(() => meta.value.page || 1);
|
||||
const showPaginationFooter = computed(
|
||||
() => totalCount.value > RESPONSES_PER_PAGE
|
||||
);
|
||||
const documentContent = computed(() => documentDetails.value?.content?.trim());
|
||||
const documentContentLength = computed(
|
||||
() => documentContent.value?.length || 0
|
||||
);
|
||||
const isPdf = computed(() => documentDetails.value?.pdf_document);
|
||||
const displayUrl = computed(() => documentDetails.value?.display_url);
|
||||
const externalLink = computed(() => documentDetails.value?.external_link);
|
||||
const sourceHref = computed(() => displayUrl.value || externalLink.value);
|
||||
const hasSafeLink = computed(() => isSafeHttpLink(sourceHref.value));
|
||||
const displayLink = computed(() => {
|
||||
if (isPdf.value) return formatDocumentLink(externalLink.value);
|
||||
return getDocumentDisplayPath(displayUrl.value || externalLink.value);
|
||||
});
|
||||
const contentTabLabel = computed(() =>
|
||||
isPdf.value
|
||||
? t('CAPTAIN.DOCUMENTS.DETAILS.PDF_TAB')
|
||||
: t('CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TAB')
|
||||
);
|
||||
const tabs = computed(() => [
|
||||
{ key: TAB_KEYS.CONTENT, label: contentTabLabel.value },
|
||||
{
|
||||
key: TAB_KEYS.FAQS,
|
||||
label: t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE'),
|
||||
count: totalCount.value,
|
||||
},
|
||||
]);
|
||||
const activeTabKey = computed(() => tabs.value[activeTabIndex.value]?.key);
|
||||
const isUnreadableContent = computed(() => {
|
||||
if (!documentContent.value) return false;
|
||||
|
||||
const content = documentContent.value;
|
||||
const sample = content.slice(0, 2000);
|
||||
const characters = Array.from(sample);
|
||||
const nonPrintableCharacters = characters.filter(character => {
|
||||
const charCode = character.charCodeAt(0);
|
||||
return (
|
||||
(charCode <= 31 && ![9, 10, 13].includes(charCode)) ||
|
||||
(charCode >= 127 && charCode <= 159)
|
||||
);
|
||||
});
|
||||
const nonPrintableRatio =
|
||||
nonPrintableCharacters.length / Math.max(characters.length, 1);
|
||||
const replacementCharacterRatio =
|
||||
characters.filter(character => character === '\uFFFD').length /
|
||||
Math.max(characters.length, 1);
|
||||
const hasPdfObjectMarkers =
|
||||
content.includes(' obj') &&
|
||||
content.includes(' endobj') &&
|
||||
content.includes(' stream');
|
||||
|
||||
return (
|
||||
content.startsWith('%PDF') ||
|
||||
hasPdfObjectMarkers ||
|
||||
nonPrintableRatio > 0.02 ||
|
||||
replacementCharacterRatio > 0.05
|
||||
);
|
||||
});
|
||||
const formattedDocumentContent = computed(() => {
|
||||
if (!documentContent.value || isUnreadableContent.value) return '';
|
||||
|
||||
const formatter = new MessageFormatter(documentContent.value);
|
||||
formatter.disableImageRendering();
|
||||
return formatter.formattedMessage;
|
||||
});
|
||||
const updatedAtLabel = computed(() => {
|
||||
if (!documentDetails.value?.updated_at) return null;
|
||||
return messageTimestamp(
|
||||
documentDetails.value.updated_at,
|
||||
'MMM d, yyyy h:mm a'
|
||||
);
|
||||
});
|
||||
const syncedAtLabel = computed(() => {
|
||||
if (!documentDetails.value?.last_synced_at) return null;
|
||||
return messageTimestamp(
|
||||
documentDetails.value.last_synced_at,
|
||||
'MMM d, yyyy h:mm a'
|
||||
);
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleCopyContent = async () => {
|
||||
try {
|
||||
await copyTextToClipboard(documentContent.value);
|
||||
useAlert(t('CAPTAIN.DOCUMENTS.DETAILS.COPY_SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.DOCUMENTS.DETAILS.COPY_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChanged = tab => {
|
||||
activeTabIndex.value = tabs.value.findIndex(item => item.key === tab.key);
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
return store.dispatch('captainResponses/get', {
|
||||
page,
|
||||
assistantId: props.captainDocument.assistant.id,
|
||||
documentId: props.captainDocument.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = page => {
|
||||
fetchResponses(page);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchResponses();
|
||||
});
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="documentDetails.name || documentDetails.external_link"
|
||||
:description="t('CAPTAIN.DOCUMENTS.DETAILS.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
overflow-y-auto
|
||||
width="3xl"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-6 min-h-48">
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium uppercase text-n-slate-10">
|
||||
{{ t('CAPTAIN.DOCUMENTS.DETAILS.SOURCE') }}
|
||||
</span>
|
||||
<a
|
||||
v-if="hasSafeLink"
|
||||
:href="sourceHref"
|
||||
:title="sourceHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center min-w-0 gap-1 text-sm text-n-slate-12 hover:underline"
|
||||
>
|
||||
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
|
||||
<span class="truncate">{{ displayLink }}</span>
|
||||
</a>
|
||||
<span v-else class="text-sm truncate text-n-slate-12">
|
||||
{{ displayLink }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium uppercase text-n-slate-10">
|
||||
{{ t('CAPTAIN.DOCUMENTS.DETAILS.GENERATED_FAQS') }}
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ totalCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium uppercase text-n-slate-10">
|
||||
{{ t('CAPTAIN.DOCUMENTS.DETAILS.LAST_UPDATED') }}
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{
|
||||
syncedAtLabel ||
|
||||
updatedAtLabel ||
|
||||
t('CAPTAIN.DOCUMENTS.DETAILS.NOT_AVAILABLE')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeTabIndex"
|
||||
@tab-changed="handleTabChanged"
|
||||
/>
|
||||
|
||||
<div class="h-[32rem] overflow-y-auto">
|
||||
<section
|
||||
v-if="activeTabKey === TAB_KEYS.CONTENT"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h4 class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
isPdf
|
||||
? t('CAPTAIN.DOCUMENTS.DETAILS.PDF_TITLE')
|
||||
: t('CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TITLE')
|
||||
}}
|
||||
</h4>
|
||||
<span
|
||||
v-if="documentContent && !isPdf"
|
||||
class="text-xs text-n-slate-10"
|
||||
>
|
||||
{{
|
||||
t('CAPTAIN.DOCUMENTS.DETAILS.CHARACTER_COUNT', {
|
||||
count: documentContentLength.toLocaleString(),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="documentContent && !isPdf"
|
||||
class="flex flex-wrap items-center justify-end gap-4"
|
||||
>
|
||||
<Button
|
||||
:label="
|
||||
showRawContent
|
||||
? t('CAPTAIN.DOCUMENTS.DETAILS.VIEW_PREVIEW')
|
||||
: t('CAPTAIN.DOCUMENTS.DETAILS.VIEW_RAW')
|
||||
"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
@click="showRawContent = !showRawContent"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CAPTAIN.DOCUMENTS.DETAILS.COPY_CONTENT')"
|
||||
icon="i-lucide-copy"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
@click="handleCopyContent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isPdf"
|
||||
class="rounded-lg border border-n-weak bg-n-alpha-1 p-4 text-sm text-n-slate-11"
|
||||
>
|
||||
<p class="mb-3">
|
||||
{{ t('CAPTAIN.DOCUMENTS.DETAILS.PDF_DESCRIPTION') }}
|
||||
</p>
|
||||
<a
|
||||
v-if="hasSafeLink"
|
||||
:href="sourceHref"
|
||||
:title="sourceHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 font-medium text-n-blue-11 hover:underline"
|
||||
>
|
||||
<Icon icon="i-ph-file-pdf" class="size-4" />
|
||||
{{ displayLink }}
|
||||
<Icon icon="i-lucide-external-link" class="size-3" />
|
||||
</a>
|
||||
<span v-else class="inline-flex items-center gap-1 text-n-slate-12">
|
||||
<Icon icon="i-ph-file-pdf" class="size-4" />
|
||||
{{ displayLink }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-else-if="documentContent">
|
||||
<div
|
||||
v-if="isUnreadableContent && !showRawContent"
|
||||
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
|
||||
>
|
||||
{{ t('CAPTAIN.DOCUMENTS.DETAILS.UNREADABLE_CONTENT') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-[26rem] overflow-y-auto rounded-lg border border-n-weak bg-n-alpha-1 p-4"
|
||||
>
|
||||
<pre
|
||||
v-if="showRawContent || isUnreadableContent"
|
||||
class="m-0 whitespace-pre-wrap break-words text-xs leading-5 text-n-slate-12"
|
||||
><code>{{ documentContent }}</code></pre>
|
||||
<div
|
||||
v-else
|
||||
v-dompurify-html="formattedDocumentContent"
|
||||
class="prose prose-sm max-w-none break-words text-n-slate-12 prose-p:my-2 prose-headings:mb-2 prose-headings:mt-4 prose-a:text-n-blue-11 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 prose-img:hidden"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
|
||||
>
|
||||
{{ t('CAPTAIN.DOCUMENTS.DETAILS.EMPTY_CONTENT') }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="activeTabKey === TAB_KEYS.FAQS"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<div v-if="responses.length" class="flex flex-col gap-3">
|
||||
<ResponseCard
|
||||
v-for="response in responses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
:status="response.status"
|
||||
:answer="response.answer"
|
||||
:assistant="response.assistant"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
|
||||
>
|
||||
{{ t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.EMPTY') }}
|
||||
</div>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
|
||||
<PaginationFooter
|
||||
:current-page="currentPage"
|
||||
:total-items="totalCount"
|
||||
:items-per-page="RESPONSES_PER_PAGE"
|
||||
class="!px-0"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ResponseCard from '../../assistant/ResponseCard.vue';
|
||||
const props = defineProps({
|
||||
captainDocument: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
const meta = useMapGetter('captainResponses/getMeta');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const totalCount = computed(() => meta.value.totalCount || 0);
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainResponses/get', {
|
||||
assistantId: props.captainDocument.assistant.id,
|
||||
documentId: props.captainDocument.id,
|
||||
});
|
||||
});
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
|
||||
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
overflow-y-auto
|
||||
width="3xl"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-3 min-h-48">
|
||||
<ResponseCard
|
||||
v-for="response in responses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
:status="response.status"
|
||||
:answer="response.answer"
|
||||
:assistant="response.assistant"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -146,6 +146,7 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
lastEmail: 'getLastEmailInSelectedChat',
|
||||
globalConfig: 'globalConfig/get',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
currentContact() {
|
||||
const senderId = this.currentChat?.meta?.sender?.id;
|
||||
@@ -173,6 +174,9 @@ export default {
|
||||
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
||||
},
|
||||
isPrivate() {
|
||||
if (this.isInstagramReplyRestricted) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
this.currentChat.can_reply ||
|
||||
this.isAWhatsAppChannel ||
|
||||
@@ -197,10 +201,16 @@ export default {
|
||||
);
|
||||
return !!stripped.trim();
|
||||
},
|
||||
// Instagram replies are disabled on Chatwoot Cloud during the temporary
|
||||
// Meta platform restriction; private notes remain available.
|
||||
isInstagramReplyRestricted() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
isReplyRestricted() {
|
||||
return (
|
||||
!this.currentChat?.can_reply &&
|
||||
!(this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
this.isInstagramReplyRestricted ||
|
||||
(!this.currentChat?.can_reply &&
|
||||
!(this.isAWhatsAppChannel || this.isAPIInbox))
|
||||
);
|
||||
},
|
||||
inboxId() {
|
||||
@@ -470,7 +480,10 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
|
||||
if (
|
||||
!this.isInstagramReplyRestricted &&
|
||||
(canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
) {
|
||||
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
||||
} else {
|
||||
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
||||
@@ -937,7 +950,10 @@ export default {
|
||||
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
||||
mode,
|
||||
});
|
||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
if (
|
||||
!this.isInstagramReplyRestricted &&
|
||||
(canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
)
|
||||
this.replyType = mode;
|
||||
if (this.isRecordingAudio) {
|
||||
this.toggleAudioRecorder();
|
||||
|
||||
@@ -811,6 +811,7 @@
|
||||
"DOCUMENTS": {
|
||||
"HEADER": "Documents",
|
||||
"ADD_NEW": "Create a new document",
|
||||
"FAQ_COUNT": "{n} FAQ | {n} FAQs",
|
||||
"SELECTED": "{count} selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
@@ -870,7 +871,27 @@
|
||||
},
|
||||
"RELATED_RESPONSES": {
|
||||
"TITLE": "Related FAQs",
|
||||
"DESCRIPTION": "These FAQs are generated directly from the document."
|
||||
"EMPTY": "No FAQs have been generated from this document yet."
|
||||
},
|
||||
"DETAILS": {
|
||||
"DESCRIPTION": "Review the crawled content and the FAQs generated from this source.",
|
||||
"SOURCE": "Source",
|
||||
"GENERATED_FAQS": "Generated FAQs",
|
||||
"LAST_UPDATED": "Last updated",
|
||||
"NOT_AVAILABLE": "Not available",
|
||||
"CONTENT_TAB": "Crawled content",
|
||||
"PDF_TAB": "PDF details",
|
||||
"CONTENT_TITLE": "Crawled content",
|
||||
"PDF_TITLE": "PDF file",
|
||||
"PDF_DESCRIPTION": "Review the original PDF source.",
|
||||
"CHARACTER_COUNT": "{count} characters extracted",
|
||||
"COPY_CONTENT": "Copy",
|
||||
"COPY_SUCCESS": "Crawled content copied to clipboard",
|
||||
"COPY_ERROR": "Could not copy crawled content",
|
||||
"VIEW_RAW": "View raw",
|
||||
"VIEW_PREVIEW": "View preview",
|
||||
"UNREADABLE_CONTENT": "Readable content could not be extracted from this document. You can view the raw extracted content.",
|
||||
"EMPTY_CONTENT": "No crawled content is available for this document yet."
|
||||
},
|
||||
"FORM_DESCRIPTION": "Enter the URL of the document to add it as a knowledge source and choose the assistant to associate it with.",
|
||||
"CREATE": {
|
||||
@@ -911,7 +932,7 @@
|
||||
},
|
||||
|
||||
"OPTIONS": {
|
||||
"VIEW_RELATED_RESPONSES": "View Related Responses",
|
||||
"VIEW_DETAILS": "View details",
|
||||
"SYNC_NOW": "Refresh now",
|
||||
"RETRY_SYNC": "Retry refresh",
|
||||
"DELETE_DOCUMENT": "Delete Document"
|
||||
|
||||
@@ -17,7 +17,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
|
||||
import DocumentDetails from 'dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue';
|
||||
import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
|
||||
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
|
||||
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
|
||||
@@ -51,22 +51,22 @@ const handleDelete = () => {
|
||||
deleteDocumentDialog.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const showRelatedResponses = ref(false);
|
||||
const showDocumentDetails = ref(false);
|
||||
const showCreateDialog = ref(false);
|
||||
const createDocumentDialog = ref(null);
|
||||
const relationQuestionDialog = ref(null);
|
||||
const documentDetailsDialog = ref(null);
|
||||
|
||||
const handleShowRelatedDocument = () => {
|
||||
showRelatedResponses.value = true;
|
||||
nextTick(() => relationQuestionDialog.value.dialogRef.open());
|
||||
const handleShowDocumentDetails = () => {
|
||||
showDocumentDetails.value = true;
|
||||
nextTick(() => documentDetailsDialog.value.dialogRef.open());
|
||||
};
|
||||
const handleCreateDocument = () => {
|
||||
showCreateDialog.value = true;
|
||||
nextTick(() => createDocumentDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleRelatedResponseClose = () => {
|
||||
showRelatedResponses.value = false;
|
||||
const handleDocumentDetailsClose = () => {
|
||||
showDocumentDetails.value = false;
|
||||
};
|
||||
|
||||
const handleCreateDialogClose = () => {
|
||||
@@ -235,8 +235,8 @@ const handleAction = ({ action, id }) => {
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
} else if (action === 'viewRelatedQuestions') {
|
||||
handleShowRelatedDocument();
|
||||
} else if (action === 'viewDetails') {
|
||||
handleShowDocumentDetails();
|
||||
} else if (action === 'sync') {
|
||||
handleSync(id);
|
||||
}
|
||||
@@ -416,6 +416,7 @@ onUnmounted(() => {
|
||||
:last-sync-error-code="doc.last_sync_error_code"
|
||||
:sync-in-progress="doc.sync_in_progress"
|
||||
:sync-stale-after-hours="syncIntervalHours"
|
||||
:responses-count="doc.responses_count"
|
||||
:is-selected="canManageDocuments && bulkSelectedIds.has(doc.id)"
|
||||
:selectable="canManageDocuments"
|
||||
:show-selection-control="shouldShowSelectionControl(doc.id)"
|
||||
@@ -427,11 +428,11 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<RelatedResponses
|
||||
v-if="showRelatedResponses"
|
||||
ref="relationQuestionDialog"
|
||||
<DocumentDetails
|
||||
v-if="showDocumentDetails"
|
||||
ref="documentDetailsDialog"
|
||||
:captain-document="selectedDocument"
|
||||
@close="handleRelatedResponseClose"
|
||||
@close="handleDocumentDetailsClose"
|
||||
/>
|
||||
<CreateDocumentDialog
|
||||
v-if="showCreateDialog"
|
||||
|
||||
@@ -104,6 +104,11 @@ class MessageFormatter {
|
||||
return this.md.render(updatedMessage);
|
||||
}
|
||||
|
||||
disableImageRendering() {
|
||||
this.md.disable(['add-image-sizing']);
|
||||
this.md.renderer.rules.image = () => '';
|
||||
}
|
||||
|
||||
get formattedMessage() {
|
||||
return this.formatMessage();
|
||||
}
|
||||
|
||||
@@ -68,6 +68,25 @@ describe('#MessageFormatter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#disableImageRendering', () => {
|
||||
it('omits nested and reference images with relative URLs', () => {
|
||||
const message = `Before ![nested [alt]](/relative.png)
|
||||
|
||||
![reference][logo]
|
||||
|
||||
[logo]: /logo.png
|
||||
|
||||
After`;
|
||||
const formatter = new MessageFormatter(message);
|
||||
|
||||
formatter.disableImageRendering();
|
||||
|
||||
expect(formatter.formattedMessage).not.toContain('<img');
|
||||
expect(formatter.formattedMessage).toContain('Before');
|
||||
expect(formatter.formattedMessage).toContain('After');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tweets', () => {
|
||||
it('should return the same string if not tags or @mentions', () => {
|
||||
const message = 'Chatwoot is an opensource tool';
|
||||
|
||||
@@ -38,7 +38,8 @@ class Imap::BaseFetchEmailService
|
||||
end
|
||||
|
||||
def email_already_present?(channel, message_id)
|
||||
channel.inbox.messages.find_by(source_id: message_id).present? || deleted_message_tracker.deleted?(message_id)
|
||||
# exists? avoids Message's default_scope ORDER BY, which full-scans large inboxes
|
||||
channel.inbox.messages.exists?(source_id: message_id) || deleted_message_tracker.deleted?(message_id)
|
||||
end
|
||||
|
||||
def deleted_message_tracker
|
||||
|
||||
@@ -40,7 +40,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
|
||||
def fetch_whatsapp_templates(url)
|
||||
response = HTTParty.get(url)
|
||||
return [] unless response.success?
|
||||
unless response.success?
|
||||
Rails.logger.warn "[WHATSAPP] Template sync failed for account #{whatsapp_channel.account_id} " \
|
||||
"inbox #{whatsapp_channel.inbox&.id}: #{response.code} #{error_message(response)}"
|
||||
return []
|
||||
end
|
||||
|
||||
next_url = next_url(response)
|
||||
|
||||
@@ -155,7 +159,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
||||
|
||||
def error_message(response)
|
||||
# https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response
|
||||
response.parsed_response&.dig('error', 'message')
|
||||
response.parsed_response.dig('error', 'message') if response.parsed_response.is_a?(Hash)
|
||||
end
|
||||
|
||||
def voice_message?(type, attachment)
|
||||
|
||||
@@ -23,7 +23,6 @@ class Whatsapp::WebhookTeardownService
|
||||
|
||||
def should_teardown_webhook?
|
||||
@channel.provider == 'whatsapp_cloud' &&
|
||||
provider_config['source'] == 'embedded_signup' &&
|
||||
provider_config['api_key'].present? &&
|
||||
(provider_config['phone_number_id'].present? || provider_config['business_account_id'].present?)
|
||||
end
|
||||
@@ -38,8 +37,11 @@ class Whatsapp::WebhookTeardownService
|
||||
Rails.logger.error "[WHATSAPP] Phone-level webhook clear failed for channel #{@channel.id}: #{e.message}"
|
||||
end
|
||||
|
||||
# The app subscription is shared by every inbox on the WABA, so only unsubscribe when this is the last one.
|
||||
# Embedded signup only — a manual token's subscribed app is the customer's, not ours to unsubscribe.
|
||||
# The subscription is shared across the WABA, so only unsubscribe when this is the last inbox.
|
||||
def unsubscribe_app_if_last_inbox(api_client)
|
||||
return unless provider_config['source'] == 'embedded_signup'
|
||||
|
||||
waba_id = provider_config['business_account_id']
|
||||
return if waba_id.blank?
|
||||
return if waba_sibling_exists?(waba_id)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
class CreateAgentSessions < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :agent_sessions do |t|
|
||||
t.integer :session_type, null: false
|
||||
t.references :subject, polymorphic: true, null: false, index: false
|
||||
t.references :result, polymorphic: true, index: false
|
||||
t.references :account, null: false, index: true
|
||||
t.references :assistant, null: false, index: true
|
||||
t.references :user, index: true
|
||||
t.string :llm_model
|
||||
t.float :credits_consumed
|
||||
t.jsonb :faq_ids, default: []
|
||||
t.jsonb :document_ids, default: []
|
||||
t.jsonb :scenario_ids, default: []
|
||||
t.jsonb :run_context, default: {}
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :agent_sessions, [:account_id, :session_type, :created_at]
|
||||
add_index :agent_sessions, [:account_id, :subject_type, :subject_id]
|
||||
add_index :agent_sessions, [:account_id, :result_type, :result_id]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
class CreateCaptainFaqSuggestions < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_faq_suggestions
|
||||
create_faq_observations
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_faq_suggestions
|
||||
create_table :captain_faq_suggestions do |t|
|
||||
t.string :question, null: false
|
||||
t.text :answer, null: false
|
||||
t.vector :embedding, limit: 1536
|
||||
t.references :assistant, null: false, index: true
|
||||
t.references :account, null: false, index: true
|
||||
t.string :language, null: false, default: 'en'
|
||||
t.integer :source_count, null: false, default: 0
|
||||
t.integer :status, null: false, default: 0
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :captain_faq_suggestions, [:account_id, :assistant_id, :status, :language],
|
||||
name: 'idx_cap_faq_suggestions_on_account_assistant_status_language'
|
||||
add_index :captain_faq_suggestions, :embedding, using: :ivfflat,
|
||||
name: 'vector_idx_captain_faq_suggestions_embedding',
|
||||
opclass: :vector_cosine_ops
|
||||
end
|
||||
|
||||
def create_faq_observations
|
||||
create_table :captain_faq_observations do |t|
|
||||
t.references :account, null: false, index: true
|
||||
t.references :conversation, null: false, index: true
|
||||
t.references :faq_suggestion, index: true
|
||||
t.string :generated_question, null: false
|
||||
t.text :generated_answer, null: false
|
||||
t.string :language, null: false, default: 'en'
|
||||
t.integer :status, null: false, default: 0
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :captain_faq_observations, [:conversation_id, :faq_suggestion_id],
|
||||
unique: true,
|
||||
where: 'faq_suggestion_id IS NOT NULL',
|
||||
name: 'idx_captain_faq_observations_on_conversation_and_suggestion'
|
||||
end
|
||||
end
|
||||
+59
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -146,6 +146,31 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
|
||||
t.index ["account_id"], name: "index_agent_capacity_policies_on_account_id"
|
||||
end
|
||||
|
||||
create_table "agent_sessions", force: :cascade do |t|
|
||||
t.integer "session_type", null: false
|
||||
t.string "subject_type", null: false
|
||||
t.bigint "subject_id", null: false
|
||||
t.string "result_type"
|
||||
t.bigint "result_id"
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "assistant_id", null: false
|
||||
t.bigint "user_id"
|
||||
t.string "llm_model"
|
||||
t.float "credits_consumed"
|
||||
t.jsonb "faq_ids", default: []
|
||||
t.jsonb "document_ids", default: []
|
||||
t.jsonb "scenario_ids", default: []
|
||||
t.jsonb "run_context", default: {}
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "result_type", "result_id"], name: "idx_on_account_id_result_type_result_id_ca66c00cd7"
|
||||
t.index ["account_id", "session_type", "created_at"], name: "idx_on_account_id_session_type_created_at_c20a14bd4e"
|
||||
t.index ["account_id", "subject_type", "subject_id"], name: "idx_on_account_id_subject_type_subject_id_6d60963b3d"
|
||||
t.index ["account_id"], name: "index_agent_sessions_on_account_id"
|
||||
t.index ["assistant_id"], name: "index_agent_sessions_on_assistant_id"
|
||||
t.index ["user_id"], name: "index_agent_sessions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "applied_slas", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "sla_policy_id", null: false
|
||||
@@ -392,6 +417,39 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
|
||||
t.index ["status"], name: "index_captain_documents_on_status"
|
||||
end
|
||||
|
||||
create_table "captain_faq_observations", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "faq_suggestion_id"
|
||||
t.string "generated_question", null: false
|
||||
t.text "generated_answer", null: false
|
||||
t.string "language", default: "en", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_captain_faq_observations_on_account_id"
|
||||
t.index ["conversation_id", "faq_suggestion_id"], name: "idx_captain_faq_observations_on_conversation_and_suggestion", unique: true, where: "(faq_suggestion_id IS NOT NULL)"
|
||||
t.index ["conversation_id"], name: "index_captain_faq_observations_on_conversation_id"
|
||||
t.index ["faq_suggestion_id"], name: "index_captain_faq_observations_on_faq_suggestion_id"
|
||||
end
|
||||
|
||||
create_table "captain_faq_suggestions", force: :cascade do |t|
|
||||
t.string "question", null: false
|
||||
t.text "answer", null: false
|
||||
t.vector "embedding", limit: 1536
|
||||
t.bigint "assistant_id", null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.string "language", default: "en", null: false
|
||||
t.integer "source_count", default: 0, null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_captain_faq_suggestions_on_account_id"
|
||||
t.index ["account_id", "assistant_id", "status", "language"], name: "idx_cap_faq_suggestions_on_account_assistant_status_language"
|
||||
t.index ["assistant_id"], name: "index_captain_faq_suggestions_on_assistant_id"
|
||||
t.index ["embedding"], name: "vector_idx_captain_faq_suggestions_embedding", opclass: :vector_cosine_ops, using: :ivfflat
|
||||
end
|
||||
|
||||
create_table "captain_inboxes", force: :cascade do |t|
|
||||
t.bigint "captain_assistant_id", null: false
|
||||
t.bigint "inbox_id", null: false
|
||||
|
||||
@@ -9,16 +9,10 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
RESULTS_PER_PAGE = 25
|
||||
|
||||
def index
|
||||
base_query = @documents
|
||||
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
|
||||
base_query = apply_source_filter(base_query, permitted_params[:source])
|
||||
base_query = apply_filter(base_query, permitted_params[:filter])
|
||||
base_query = apply_search(base_query, permitted_params[:search_key])
|
||||
base_query = apply_sort(base_query, permitted_params[:sort])
|
||||
|
||||
@documents_count = base_query.count
|
||||
@documents = filtered_documents
|
||||
@documents_count = @documents.count
|
||||
@sync_interval_hours = current_sync_interval&.in_hours&.to_i
|
||||
@documents = base_query.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
@documents = with_responses_count(@documents).page(@current_page).per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def show; end
|
||||
@@ -59,6 +53,21 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
@documents = Current.account.captain_documents.with_attached_pdf_file.includes(:assistant)
|
||||
end
|
||||
|
||||
def filtered_documents
|
||||
documents = @documents
|
||||
documents = documents.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
|
||||
documents = apply_source_filter(documents, permitted_params[:source])
|
||||
documents = apply_filter(documents, permitted_params[:filter])
|
||||
documents = apply_search(documents, permitted_params[:search_key])
|
||||
apply_sort(documents, permitted_params[:sort])
|
||||
end
|
||||
|
||||
def with_responses_count(scope)
|
||||
scope.left_joins(:responses)
|
||||
.select('captain_documents.*, COUNT(captain_assistant_responses.id) AS responses_count')
|
||||
.group('captain_documents.id')
|
||||
end
|
||||
|
||||
def set_document
|
||||
@document = @documents.find(permitted_params[:id])
|
||||
end
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: agent_sessions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# credits_consumed :float
|
||||
# document_ids :jsonb
|
||||
# faq_ids :jsonb
|
||||
# llm_model :string
|
||||
# result_type :string
|
||||
# run_context :jsonb
|
||||
# scenario_ids :jsonb
|
||||
# session_type :integer not null
|
||||
# subject_type :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assistant_id :bigint not null
|
||||
# result_id :bigint
|
||||
# subject_id :bigint not null
|
||||
# user_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_on_account_id_result_type_result_id_ca66c00cd7 (account_id,result_type,result_id)
|
||||
# idx_on_account_id_session_type_created_at_c20a14bd4e (account_id,session_type,created_at)
|
||||
# idx_on_account_id_subject_type_subject_id_6d60963b3d (account_id,subject_type,subject_id)
|
||||
# index_agent_sessions_on_account_id (account_id)
|
||||
# index_agent_sessions_on_assistant_id (assistant_id)
|
||||
# index_agent_sessions_on_user_id (user_id)
|
||||
#
|
||||
class Captain::AgentSession < ApplicationRecord
|
||||
self.table_name = 'agent_sessions'
|
||||
|
||||
SUBJECT_TYPES = { 'assistant' => 'Conversation', 'copilot' => 'CopilotThread' }.freeze
|
||||
RESULT_TYPES = { 'assistant' => 'Message', 'copilot' => 'CopilotMessage' }.freeze
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
belongs_to :user, optional: true
|
||||
belongs_to :subject, ->(session) { where(account_id: session.account_id) }, polymorphic: true
|
||||
belongs_to :result, ->(session) { where(account_id: session.account_id) }, polymorphic: true, optional: true
|
||||
|
||||
enum :session_type, { assistant: 0, copilot: 1 }, prefix: :session
|
||||
|
||||
before_validation :ensure_account
|
||||
|
||||
validate :subject_type_matches_session_type
|
||||
validate :result_type_matches_session_type, if: -> { result_type.present? }
|
||||
validate :subject_belongs_to_account
|
||||
validate :result_belongs_to_account, if: -> { result_id.present? }
|
||||
|
||||
private
|
||||
|
||||
def ensure_account
|
||||
self.account = assistant&.account
|
||||
end
|
||||
|
||||
def subject_type_matches_session_type
|
||||
expected_type = SUBJECT_TYPES[session_type]
|
||||
return if subject_type == expected_type
|
||||
|
||||
errors.add(:subject_type, "must be #{expected_type} for #{session_type} sessions")
|
||||
end
|
||||
|
||||
def result_type_matches_session_type
|
||||
expected_type = RESULT_TYPES[session_type]
|
||||
return if result_type == expected_type
|
||||
|
||||
errors.add(:result_type, "must be #{expected_type} for #{session_type} sessions")
|
||||
end
|
||||
|
||||
def subject_belongs_to_account
|
||||
return if subject.nil? || subject.account_id == account_id
|
||||
|
||||
errors.add(:subject, 'must belong to the session account')
|
||||
end
|
||||
|
||||
def result_belongs_to_account
|
||||
target_class = result_type.safe_constantize
|
||||
actual_account_id = target_class && target_class.unscoped.where(id: result_id).pick(:account_id)
|
||||
return if actual_account_id == account_id
|
||||
|
||||
errors.add(:result, 'must belong to the session account')
|
||||
end
|
||||
end
|
||||
@@ -28,6 +28,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
belongs_to :account
|
||||
has_many :documents, class_name: 'Captain::Document', dependent: :destroy_async
|
||||
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy_async
|
||||
has_many :faq_suggestions, class_name: 'Captain::FaqSuggestion', dependent: :destroy_async
|
||||
has_many :captain_inboxes,
|
||||
class_name: 'CaptainInbox',
|
||||
foreign_key: :captain_assistant_id,
|
||||
@@ -37,6 +38,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
has_many :messages, as: :sender, dependent: :nullify
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
|
||||
has_many :agent_sessions, class_name: 'Captain::AgentSession', dependent: :destroy_async
|
||||
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_faq_observations
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# generated_answer :text not null
|
||||
# generated_question :string not null
|
||||
# language :string default("en"), not null
|
||||
# status :integer default("attached"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# faq_suggestion_id :bigint
|
||||
#
|
||||
class Captain::FaqObservation < ApplicationRecord
|
||||
self.table_name = 'captain_faq_observations'
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :conversation, class_name: '::Conversation'
|
||||
belongs_to :faq_suggestion, class_name: 'Captain::FaqSuggestion', optional: true, inverse_of: :observations
|
||||
|
||||
enum status: { attached: 0, discarded: 1 }
|
||||
|
||||
validates :generated_question, :generated_answer, :language, presence: true
|
||||
validates :faq_suggestion, presence: true, if: :attached?
|
||||
validate :faq_suggestion_belongs_to_account
|
||||
|
||||
before_validation :ensure_account
|
||||
|
||||
private
|
||||
|
||||
def ensure_account
|
||||
self.account = conversation&.account
|
||||
end
|
||||
|
||||
def faq_suggestion_belongs_to_account
|
||||
return if faq_suggestion.blank? || faq_suggestion.account_id == account_id
|
||||
|
||||
errors.add(:faq_suggestion, :invalid)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_faq_suggestions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# answer :text not null
|
||||
# embedding :vector(1536)
|
||||
# language :string default("en"), not null
|
||||
# question :string not null
|
||||
# source_count :integer default(0), not null
|
||||
# status :integer default("open"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assistant_id :bigint not null
|
||||
#
|
||||
class Captain::FaqSuggestion < ApplicationRecord
|
||||
self.table_name = 'captain_faq_suggestions'
|
||||
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
belongs_to :account
|
||||
has_many :observations,
|
||||
class_name: 'Captain::FaqObservation',
|
||||
dependent: :delete_all,
|
||||
inverse_of: :faq_suggestion
|
||||
has_neighbors :embedding, normalize: true
|
||||
|
||||
enum status: { open: 0, approved: 1, dismissed: 2 }
|
||||
|
||||
validates :question, :answer, :language, presence: true
|
||||
|
||||
before_validation :ensure_account
|
||||
after_commit :update_embedding, on: [:create, :update]
|
||||
|
||||
scope :ordered, -> { order(source_count: :desc, updated_at: :desc) }
|
||||
scope :by_language, ->(language) { where(language: language) }
|
||||
|
||||
private
|
||||
|
||||
def ensure_account
|
||||
self.account = assistant&.account
|
||||
end
|
||||
|
||||
def update_embedding
|
||||
return unless open?
|
||||
return unless saved_change_to_question? || saved_change_to_answer? || embedding.nil?
|
||||
return if previously_new_record? && embedding.present?
|
||||
|
||||
Captain::Llm::UpdateEmbeddingJob.perform_later(self, "#{question}: #{answer}")
|
||||
end
|
||||
end
|
||||
@@ -11,8 +11,11 @@ module Enterprise::Concerns::Account
|
||||
|
||||
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
|
||||
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
|
||||
has_many :captain_faq_observations, dependent: :destroy_async, class_name: 'Captain::FaqObservation'
|
||||
has_many :captain_faq_suggestions, dependent: :destroy_async, class_name: 'Captain::FaqSuggestion'
|
||||
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
||||
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
|
||||
has_many :captain_agent_sessions, dependent: :destroy_async, class_name: 'Captain::AgentSession'
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :companies, dependent: :destroy_async
|
||||
|
||||
@@ -7,6 +7,7 @@ module Enterprise::Concerns::Conversation
|
||||
has_many :sla_events, dependent: :destroy_async
|
||||
has_many :calls, dependent: :destroy_async
|
||||
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
|
||||
has_many :captain_faq_observations, class_name: 'Captain::FaqObservation', dependent: :delete_all
|
||||
scope :with_sla_applicable_contact, -> { left_joins(:contact).where(contacts: { blocked: [false, nil] }) }
|
||||
|
||||
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
|
||||
|
||||
@@ -9,6 +9,8 @@ json.external_link resource.external_link
|
||||
json.display_url resource.display_url
|
||||
json.file_size resource.file_size
|
||||
json.pdf_document resource.pdf_document?
|
||||
responses_count = resource.respond_to?(:responses_count) ? resource.responses_count : resource.responses.count
|
||||
json.responses_count responses_count.to_i
|
||||
json.id resource.id
|
||||
json.name resource.name
|
||||
json.status resource.status
|
||||
|
||||
@@ -12,7 +12,7 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :conversation_display_id!]
|
||||
|
||||
def perform
|
||||
content = format_messages_as_string
|
||||
content = format_evaluation_input
|
||||
return default_incomplete_response('No messages found') if content.blank?
|
||||
|
||||
response = make_api_call(
|
||||
@@ -35,12 +35,58 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
|
||||
Rails.root.join('enterprise/lib/captain/prompts', "#{file_name}.liquid").read
|
||||
end
|
||||
|
||||
def format_messages_as_string
|
||||
messages = conversation_messages(start_from: 0)
|
||||
messages.map do |msg|
|
||||
sender_type = msg[:role] == 'user' ? 'Customer' : 'Assistant'
|
||||
"#{sender_type}: #{msg[:content]}"
|
||||
def format_evaluation_input
|
||||
messages = conversation_message_records(start_from: 0)
|
||||
return if messages.blank?
|
||||
|
||||
[
|
||||
"Conversation status: #{conversation.status}",
|
||||
format_messages_as_string(messages)
|
||||
].join("\n\n")
|
||||
end
|
||||
|
||||
def conversation_message_records(start_from: 0)
|
||||
messages = []
|
||||
character_count = start_from
|
||||
|
||||
conversation.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.reorder('id desc')
|
||||
.each do |message|
|
||||
content = message.content_for_llm
|
||||
next if content.blank?
|
||||
break if character_count + content.length > TOKEN_LIMIT
|
||||
|
||||
messages.prepend({ message: message, content: content })
|
||||
character_count += content.length
|
||||
end
|
||||
|
||||
messages
|
||||
end
|
||||
|
||||
def format_messages_as_string(messages)
|
||||
transcript = messages.map do |message_context|
|
||||
"#{message_sender_label(message_context[:message])}: #{message_context[:content]}"
|
||||
end.join("\n")
|
||||
|
||||
"Conversation transcript:\n#{transcript}"
|
||||
end
|
||||
|
||||
def message_sender_label(message)
|
||||
return 'Customer' if message.incoming?
|
||||
return 'Captain' if captain_reply?(message)
|
||||
return 'Bot' if bot_reply?(message)
|
||||
|
||||
'Assistant'
|
||||
end
|
||||
|
||||
def captain_reply?(message)
|
||||
message.outgoing? && message.sender_type == 'Captain::Assistant'
|
||||
end
|
||||
|
||||
def bot_reply?(message)
|
||||
message.outgoing? && message.sender_type.in?(['AgentBot', 'Captain::Assistant'])
|
||||
end
|
||||
|
||||
def parse_response(message)
|
||||
|
||||
@@ -2,18 +2,39 @@ You are evaluating whether a customer support conversation is complete and can b
|
||||
|
||||
The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language.
|
||||
|
||||
You will receive:
|
||||
- Conversation status
|
||||
- Conversation transcript where messages are labeled as Customer, Captain, Bot, or Assistant
|
||||
|
||||
This evaluator runs for inactive pending conversations. Focus on the latest pending exchange or latest unresolved customer request. Older messages may be present only for context.
|
||||
If the conversation status is "pending", the conversation is still with Captain. Do not assume a handoff happened because Captain mentioned one.
|
||||
|
||||
A conversation is INCOMPLETE (keep open) if ANY of these apply:
|
||||
- The assistant asked a question or requested information that the customer hasn't provided
|
||||
- The customer asked a question that wasn't fully answered
|
||||
- The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet
|
||||
- The customer raised multiple questions or issues and not all were addressed
|
||||
- In the latest pending exchange, Captain, Bot, or Assistant said it handed off, will hand off, escalated, will escalate, or that a human/team/another party will continue the work
|
||||
- In the latest pending exchange, Captain, Bot, or Assistant promised future action or follow-up instead of resolving the customer's request
|
||||
- In the latest pending exchange, the customer is waiting for another party's action, response, status update, or investigation result
|
||||
- The latest customer message is only an attachment placeholder such as "[Attachment]" and there is no later text explaining what it contains or showing the issue was answered
|
||||
- The customer says they were not helped, asks why nobody replied, repeats the unresolved issue after a previous answer, or otherwise indicates dissatisfaction with the current help
|
||||
|
||||
Do NOT treat these as incomplete by themselves:
|
||||
- A generic greeting or broad optional offer from Captain/Bot/Assistant, such as "How can I help?", "What would you like to know?", or "Anything else?", when the customer has not made a recognizable request
|
||||
- A customer greeting, single-word reply, name, phone number, or gibberish with no recognizable question/request, followed only by Captain/Bot/Assistant asking what the customer needs
|
||||
- An optional invitation for the customer to ask more questions after the assistant already answered the actual request
|
||||
- Older handoff, escalation, or follow-up messages from a previous exchange when the latest customer message starts a new topic, has no recognizable request, or has already been answered
|
||||
|
||||
Important handoff rule:
|
||||
- A handoff, escalation, transfer, acknowledgement, or promise of future follow-up is not a resolution by itself
|
||||
- If conversation status is "pending" and Captain/Bot/Assistant says it handed off, will hand off, or that another party will continue the work in the latest pending exchange, keep the conversation INCOMPLETE.
|
||||
|
||||
A conversation is COMPLETE only if ALL of these are true:
|
||||
- The assistant's answer fully addressed the customer's question or issue and is self-contained — it requires no further action from the customer
|
||||
- There are no unanswered questions, unmet requests, or outstanding follow-ups from either side
|
||||
- Note: customers often do not explicitly say thanks or confirm resolution. If the assistant gave a complete, self-contained answer and the customer had no follow-up, that is sufficient. Do not require explicit gratitude or confirmation.
|
||||
- If the customer sent only one or two short messages (single words, names, phone numbers, or gibberish) with no recognizable question or request across the entire conversation, and the
|
||||
assistant has responded asking for clarification, the conversation is COMPLETE.
|
||||
- If the customer sent only one or two short text messages (greetings, single words, names, phone numbers, or gibberish) with no recognizable question or request across the entire conversation, and Captain/Bot/Assistant has responded asking what they need or offering help, the conversation is COMPLETE.
|
||||
|
||||
Analyze the conversation and respond with ONLY a JSON object (no other text):
|
||||
{"complete": true, "reason": "brief explanation"}
|
||||
|
||||
@@ -51,6 +51,18 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
expect(json_response[:payload].length).to eq(5)
|
||||
expect(json_response[:meta]).to eq({ page: 2, total_count: 30 })
|
||||
end
|
||||
|
||||
it 'returns the generated FAQ count for each document' do
|
||||
document = create(:captain_document, assistant: assistant, account: account)
|
||||
create_list(:captain_assistant_response, 2,
|
||||
assistant: assistant, account: account, documentable: document)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/captain/documents",
|
||||
headers: agent.create_new_auth_token, as: :json
|
||||
|
||||
matching_document = json_response[:payload].find { |item| item[:id] == document.id }
|
||||
expect(matching_document[:responses_count]).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filtering by assistant_id' do
|
||||
@@ -142,6 +154,10 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
expect(json_response[:external_link]).to eq(document.external_link)
|
||||
end
|
||||
|
||||
it 'returns the crawled content for the document' do
|
||||
expect(json_response[:content]).to eq(document.content)
|
||||
end
|
||||
|
||||
it 'returns sync metadata when the document has been synced' do
|
||||
synced_at = 1.hour.ago
|
||||
document.update!(sync_status: :synced, last_synced_at: synced_at)
|
||||
|
||||
@@ -68,6 +68,110 @@ RSpec.describe Captain::ConversationCompletionService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when building evaluation context' do
|
||||
let(:captain_assistant) { create(:captain_assistant, account: account) }
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: { 'complete' => false, 'reason' => 'Human follow-up is still pending' },
|
||||
input_tokens: 100,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes conversation status and speaker labels' do
|
||||
conversation.update!(status: :pending, waiting_since: 2.hours.ago)
|
||||
create(:message, conversation: conversation, inbox: inbox, account: account, message_type: :incoming, content: 'I need help with a refund')
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
account: account,
|
||||
message_type: :outgoing,
|
||||
sender: captain_assistant,
|
||||
content: 'I will transfer this to support for review.'
|
||||
)
|
||||
|
||||
expect(mock_chat).to receive(:ask) do |content|
|
||||
expect(content).to include(
|
||||
'Conversation status: pending',
|
||||
'Conversation transcript:',
|
||||
'Customer: I need help with a refund',
|
||||
'Captain: I will transfer this to support for review.'
|
||||
)
|
||||
|
||||
mock_response
|
||||
end
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
end
|
||||
|
||||
it 'includes pending captain handoff evidence in the transcript' do
|
||||
conversation.update!(status: :pending)
|
||||
create(:message, conversation: conversation, inbox: inbox, account: account, message_type: :incoming, content: 'Please cancel my order')
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
account: account,
|
||||
message_type: :outgoing,
|
||||
sender: captain_assistant,
|
||||
content: 'I will transfer this to a specialist and they will follow up here.'
|
||||
)
|
||||
|
||||
expect(mock_chat).to receive(:ask) do |content|
|
||||
expect(content).to include(
|
||||
'Conversation status: pending',
|
||||
'Captain: I will transfer this to a specialist and they will follow up here.'
|
||||
)
|
||||
|
||||
mock_response
|
||||
end
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
end
|
||||
|
||||
it 'reuses computed message content while formatting the transcript' do
|
||||
content_for_llm_calls_by_message_id = Hash.new(0)
|
||||
allow_any_instance_of(Message).to receive(:content_for_llm).and_wrap_original do |method, *args| # rubocop:disable RSpec/AnyInstance
|
||||
content_for_llm_calls_by_message_id[method.receiver.id] += 1
|
||||
method.call(*args)
|
||||
end
|
||||
|
||||
incoming_message = create(
|
||||
:message,
|
||||
:with_attachment,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
account: account,
|
||||
message_type: :incoming,
|
||||
content: nil
|
||||
)
|
||||
outgoing_message = create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
inbox: inbox,
|
||||
account: account,
|
||||
message_type: :outgoing,
|
||||
sender: captain_assistant,
|
||||
content: 'What do you need help with?'
|
||||
)
|
||||
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
|
||||
service.perform
|
||||
|
||||
expect(content_for_llm_calls_by_message_id).to include(
|
||||
incoming_message.id => 1,
|
||||
outgoing_message.id => 1
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has no messages' do
|
||||
it 'returns incomplete with appropriate reason' do
|
||||
result = service.perform
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::AgentSession, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
|
||||
it { is_expected.to belong_to(:user).optional }
|
||||
it { is_expected.to belong_to(:subject) }
|
||||
it { is_expected.to belong_to(:result).optional }
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
it { is_expected.to define_enum_for(:session_type).with_values(assistant: 0, copilot: 1).with_prefix(:session) }
|
||||
end
|
||||
|
||||
describe '#subject' do
|
||||
it 'returns the conversation for an assistant session' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation)
|
||||
|
||||
expect(session.subject).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'returns the copilot thread for a copilot session' do
|
||||
user = create(:user, account: account)
|
||||
copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant)
|
||||
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user, subject: copilot_thread)
|
||||
|
||||
expect(session.subject).to eq(copilot_thread)
|
||||
end
|
||||
|
||||
it 'returns nil when the subject record no longer exists' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation)
|
||||
conversation.destroy
|
||||
|
||||
expect(session.reload.subject).to be_nil
|
||||
end
|
||||
|
||||
it 'is not valid when the subject type does not match the session type' do
|
||||
copilot_thread = create(:captain_copilot_thread, account: account, user: create(:user, account: account), assistant: assistant)
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: copilot_thread)
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:subject_type]).to be_present
|
||||
end
|
||||
|
||||
it 'is not valid when the subject belongs to a different account' do
|
||||
foreign_conversation = create(:conversation, account: create(:account))
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: foreign_conversation)
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:subject]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe '#result' do
|
||||
it 'returns the message for an assistant session' do
|
||||
conversation = create(:conversation, account: account)
|
||||
message = create(:message, account: account, conversation: conversation)
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: message)
|
||||
|
||||
expect(session.result).to eq(message)
|
||||
end
|
||||
|
||||
it 'returns the copilot message for a copilot session' do
|
||||
user = create(:user, account: account)
|
||||
copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant)
|
||||
copilot_message = create(:captain_copilot_message, account: account, copilot_thread: copilot_thread)
|
||||
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user,
|
||||
subject: copilot_thread, result: copilot_message)
|
||||
|
||||
expect(session.result).to eq(copilot_message)
|
||||
end
|
||||
|
||||
it 'returns nil when result_id is nil' do
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant)
|
||||
|
||||
expect(session.result).to be_nil
|
||||
end
|
||||
|
||||
it 'is not valid when the result belongs to a different account' do
|
||||
conversation = create(:conversation, account: account)
|
||||
foreign_message = create(:message, account: create(:account))
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: foreign_message)
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:result]).to be_present
|
||||
end
|
||||
|
||||
it 'is not valid when result_id/result_type are set directly for a different account' do
|
||||
conversation = create(:conversation, account: account)
|
||||
foreign_message = create(:message, account: create(:account))
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation,
|
||||
result_id: foreign_message.id, result_type: 'Message')
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:result]).to be_present
|
||||
end
|
||||
|
||||
it 'is not valid when result_id/result_type are set directly for a stale id' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation,
|
||||
result_id: 0, result_type: 'Message')
|
||||
|
||||
expect(session).not_to be_valid
|
||||
expect(session.errors[:result]).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
describe 'account' do
|
||||
it 'is derived from the assistant when created via the assistant association' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = assistant.agent_sessions.create!(subject: conversation, session_type: :assistant)
|
||||
|
||||
expect(session.account).to eq(account)
|
||||
end
|
||||
|
||||
it 'overrides a mismatched explicit account with the assistant account' do
|
||||
conversation = create(:conversation, account: account)
|
||||
session = build(:captain_agent_session, account: create(:account), assistant: assistant, subject: conversation)
|
||||
|
||||
expect(session).to be_valid
|
||||
expect(session.account).to eq(account)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'defaults' do
|
||||
it 'defaults faq_ids, document_ids, scenario_ids and run_context' do
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant)
|
||||
|
||||
expect(session.faq_ids).to eq([])
|
||||
expect(session.document_ids).to eq([])
|
||||
expect(session.scenario_ids).to eq([])
|
||||
expect(session.run_context).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
describe 'factory' do
|
||||
it 'builds a valid assistant session' do
|
||||
session = create(:captain_agent_session, account: account, assistant: assistant)
|
||||
|
||||
expect(session).to be_valid
|
||||
expect(session).to be_session_assistant
|
||||
expect(session.subject).to be_a(Conversation)
|
||||
end
|
||||
|
||||
it 'builds a valid copilot session' do
|
||||
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant)
|
||||
|
||||
expect(session).to be_valid
|
||||
expect(session).to be_session_copilot
|
||||
expect(session.subject).to be_a(CopilotThread)
|
||||
expect(session.user).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
FactoryBot.define do
|
||||
factory :captain_agent_session, class: 'Captain::AgentSession' do
|
||||
account
|
||||
association :assistant, factory: :captain_assistant
|
||||
session_type { :assistant }
|
||||
subject { create(:conversation, account: account) }
|
||||
|
||||
trait :copilot do
|
||||
session_type { :copilot }
|
||||
user
|
||||
subject { create(:captain_copilot_thread, account: account, user: user) }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -51,18 +51,41 @@ RSpec.describe Whatsapp::WebhookTeardownService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is whatsapp_cloud but not embedded_signup' do
|
||||
context 'when channel is whatsapp_cloud with manual setup' do
|
||||
before do
|
||||
allow(channel).to receive(:setup_webhooks).and_return(true)
|
||||
|
||||
channel.update!(
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: { 'source' => 'manual' }
|
||||
provider_config: {
|
||||
'source' => 'manual',
|
||||
'phone_number_id' => 'manual_phone_id',
|
||||
'business_account_id' => 'manual_waba_id',
|
||||
'api_key' => 'manual_api_key'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not attempt to unsubscribe webhook' do
|
||||
expect(Whatsapp::FacebookApiClient).not_to receive(:new)
|
||||
it 'clears the phone number callback override' do
|
||||
api_client = instance_double(Whatsapp::FacebookApiClient)
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).with('manual_api_key').and_return(api_client)
|
||||
allow(api_client).to receive(:clear_phone_number_callback_override).with('manual_phone_id')
|
||||
|
||||
service.perform
|
||||
|
||||
expect(api_client).to have_received(:clear_phone_number_callback_override).with('manual_phone_id')
|
||||
end
|
||||
|
||||
# The manual token belongs to the customer's own Meta app, so its WABA subscription is not ours to remove.
|
||||
it 'does not unsubscribe the app from the WABA' do
|
||||
api_client = instance_double(Whatsapp::FacebookApiClient)
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
|
||||
allow(api_client).to receive(:clear_phone_number_callback_override)
|
||||
allow(api_client).to receive(:unsubscribe_app_from_waba)
|
||||
|
||||
service.perform
|
||||
|
||||
expect(api_client).not_to have_received(:unsubscribe_app_from_waba)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user