feat: show crawled document details and FAQ counts in Captain (#14863)
- Add a document details view that surfaces crawled content, source metadata, and generated FAQ counts. - Rename the document card action to open details and show the FAQ count inline in the list. - Return `responses_count` from the documents API efficiently and expose document content in the show payload. - Update related Captain copy to reflect the new details-oriented flow. **Preview** <img width="1640" height="1596" alt="CleanShot 2026-06-26 at 09 25 15@2x" src="https://github.com/user-attachments/assets/0c408fae-7d37-422a-8869-ece466292cb1" /> --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
This commit is contained in:
co-authored by
Muhsin
Sony Mathew
Sony Mathew
parent
11c65f3b9a
commit
08260f3be7
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user