diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue
index 9d6d574ec..8ff38b2eb 100644
--- a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue
+++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue
@@ -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 = () => {
-
+
+
{
{{ displayLink }}
+
+ {{ responsesCountLabel }}
+
({
+ 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: '
',
+};
+
+const TabBarStub = {
+ name: 'TabBar',
+ template:
+ '',
+};
+
+const PaginationFooterStub = {
+ name: 'PaginationFooter',
+ template:
+ '',
+};
+
+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,
+ });
+ });
+});
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue b/app/javascript/dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue
new file mode 100644
index 000000000..eadb0b480
--- /dev/null
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue
@@ -0,0 +1,374 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue b/app/javascript/dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue
deleted file mode 100644
index 9c95fd2b4..000000000
--- a/app/javascript/dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
-
-
diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json
index d3dc538a9..629dbd27c 100644
--- a/app/javascript/dashboard/i18n/locale/en/integrations.json
+++ b/app/javascript/dashboard/i18n/locale/en/integrations.json
@@ -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"
diff --git a/app/javascript/dashboard/routes/dashboard/captain/documents/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/documents/Index.vue
index 87c04aefe..9c1effd20 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/documents/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/documents/Index.vue
@@ -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(() => {
-
'';
+ }
+
get formattedMessage() {
return this.formatMessage();
}
diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
index 3350399eb..20d64005a 100644
--- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
+++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
@@ -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('
{
it('should return the same string if not tags or @mentions', () => {
const message = 'Chatwoot is an opensource tool';
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
index 273c082b1..d88cc6b48 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
@@ -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
diff --git a/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder
index 56260f675..0ab031dbf 100644
--- a/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder
+++ b/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder
@@ -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
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb
index 77cb25f49..4d4b10fcb 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb
@@ -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)