feat: support uploading pdf in document knowledgebase
This commit is contained in:
+10
@@ -94,3 +94,13 @@ yarn-debug.log*
|
||||
.vscode
|
||||
.claude/settings.local.json
|
||||
.cursor
|
||||
|
||||
bin/
|
||||
local/
|
||||
cache/
|
||||
gems/
|
||||
specifications/
|
||||
extensions/
|
||||
build_info/
|
||||
bin
|
||||
.pnpm-store/
|
||||
|
||||
@@ -15,6 +15,14 @@ class CaptainDocument extends ApiClient {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
uploadPdf(formData) {
|
||||
return axios.post(`${this.url}/upload_pdf`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainDocument();
|
||||
|
||||
+22
-5
@@ -18,12 +18,29 @@ const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
|
||||
|
||||
const handleSubmit = async newDocument => {
|
||||
try {
|
||||
await store.dispatch('captainDocuments/create', newDocument);
|
||||
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
|
||||
if (newDocument.type === 'pdf') {
|
||||
// Handle PDF upload
|
||||
const formData = new FormData();
|
||||
formData.append('pdf_document', newDocument.pdf_document);
|
||||
formData.append('assistant_id', newDocument.assistant_id);
|
||||
|
||||
await store.dispatch('captainDocuments/uploadPdf', formData);
|
||||
useAlert('PDF uploaded successfully! Processing will begin shortly.');
|
||||
} else {
|
||||
// Handle URL creation
|
||||
await store.dispatch('captainDocuments/create', {
|
||||
document: {
|
||||
external_link: newDocument.external_link,
|
||||
assistant_id: newDocument.assistant_id,
|
||||
},
|
||||
});
|
||||
useAlert('Document created successfully!');
|
||||
}
|
||||
dialogRef.value.close();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message || t(`${i18nKey}.ERROR_MESSAGE`);
|
||||
error?.response?.data?.message ||
|
||||
'Failed to create document. Please try again.';
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
@@ -42,8 +59,8 @@ defineExpose({ dialogRef });
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
:title="$t(`${i18nKey}.TITLE`)"
|
||||
:description="$t('CAPTAIN.DOCUMENTS.FORM_DESCRIPTION')"
|
||||
title="Create New Document"
|
||||
description="Add a new document from a website URL or by uploading a PDF file"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="handleClose"
|
||||
|
||||
+220
-27
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { reactive, computed } from 'vue';
|
||||
import { reactive, computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, url } from '@vuelidate/validators';
|
||||
@@ -8,6 +8,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import FileUpload from 'dashboard/components-next/fileUpload/FileUpload.vue';
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
@@ -19,16 +20,26 @@ const formState = {
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
name: '',
|
||||
url: '',
|
||||
assistantId: null,
|
||||
selectedFile: null,
|
||||
sourceType: 'url', // 'url' or 'pdf'
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
const uploadError = ref('');
|
||||
|
||||
const validationRules = {
|
||||
url: { required, url, minLength: minLength(1) },
|
||||
assistantId: { required },
|
||||
};
|
||||
const validationRules = computed(() => {
|
||||
const rules = {
|
||||
assistantId: { required },
|
||||
};
|
||||
|
||||
if (state.sourceType === 'url') {
|
||||
rules.url = { required, url, minLength: minLength(1) };
|
||||
}
|
||||
|
||||
return rules;
|
||||
});
|
||||
|
||||
const assistantList = computed(() =>
|
||||
formState.assistants.value.map(assistant => ({
|
||||
@@ -41,70 +52,252 @@ const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
return v$.value[field].$error
|
||||
? t(`CAPTAIN.DOCUMENTS.FORM.${errorKey}.ERROR`)
|
||||
: '';
|
||||
const getErrorMessage = (field, errorMessage) => {
|
||||
return v$.value[field]?.$error ? errorMessage : '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
url: getErrorMessage('url', 'URL'),
|
||||
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
|
||||
url: getErrorMessage('url', 'Please enter a valid URL'),
|
||||
assistantId: getErrorMessage('assistantId', 'Please select an assistant'),
|
||||
}));
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
|
||||
const prepareDocumentDetails = () => ({
|
||||
external_link: state.url,
|
||||
assistant_id: state.assistantId,
|
||||
});
|
||||
const prepareDocumentDetails = () => {
|
||||
if (state.sourceType === 'pdf') {
|
||||
return {
|
||||
type: 'pdf',
|
||||
pdf_document: state.selectedFile,
|
||||
assistant_id: state.assistantId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'url',
|
||||
external_link: state.url,
|
||||
assistant_id: state.assistantId,
|
||||
};
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
uploadError.value = '';
|
||||
|
||||
// Validate form
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For PDF uploads, check if file is selected
|
||||
if (state.sourceType === 'pdf' && !state.selectedFile) {
|
||||
uploadError.value = 'Please select a PDF file to upload.';
|
||||
return;
|
||||
}
|
||||
|
||||
emit('submit', prepareDocumentDetails());
|
||||
};
|
||||
|
||||
// PDF Upload handlers
|
||||
const handleFileSelected = file => {
|
||||
state.selectedFile = file;
|
||||
uploadError.value = '';
|
||||
};
|
||||
|
||||
const handleFileError = error => {
|
||||
uploadError.value = error;
|
||||
state.selectedFile = null;
|
||||
};
|
||||
|
||||
const clearSelectedFile = () => {
|
||||
state.selectedFile = null;
|
||||
uploadError.value = '';
|
||||
};
|
||||
|
||||
const handleSourceTypeChange = type => {
|
||||
state.sourceType = type;
|
||||
uploadError.value = '';
|
||||
state.selectedFile = null;
|
||||
state.url = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<Input
|
||||
v-model="state.url"
|
||||
:label="t('CAPTAIN.DOCUMENTS.FORM.URL.LABEL')"
|
||||
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.URL.PLACEHOLDER')"
|
||||
:message="formErrors.url"
|
||||
:message-type="formErrors.url ? 'error' : 'info'"
|
||||
/>
|
||||
<!-- Source Type Selection -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
Choose Source Type
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 p-3 rounded-lg border border-n-slate-6 hover:border-n-slate-7 transition-colors"
|
||||
:class="{
|
||||
'bg-primary-50 border-primary-500 text-primary-700':
|
||||
state.sourceType === 'url',
|
||||
'bg-n-alpha-2 text-n-slate-10': state.sourceType !== 'url',
|
||||
}"
|
||||
@click="handleSourceTypeChange('url')"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3.9 12C3.9 10.29 5.29 8.9 7 8.9H11V7H7C4.24 7 2 9.24 2 12S4.24 17 7 17H11V15.1H7C5.29 15.1 3.9 13.71 3.9 12ZM8 13H16V11H8V13ZM17 7H13V8.9H17C18.71 8.9 20.1 10.29 20.1 12S18.71 15.1 17 15.1H13V17H17C19.76 17 22 14.76 22 12S19.76 7 17 7Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Website URL</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 p-3 rounded-lg border border-n-slate-6 hover:border-n-slate-7 transition-colors"
|
||||
:class="{
|
||||
'bg-primary-50 border-primary-500 text-primary-700':
|
||||
state.sourceType === 'pdf',
|
||||
'bg-n-alpha-2 text-n-slate-10': state.sourceType !== 'pdf',
|
||||
}"
|
||||
@click="handleSourceTypeChange('pdf')"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">PDF Upload</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL Input (when URL is selected) -->
|
||||
<div v-if="state.sourceType === 'url'">
|
||||
<Input
|
||||
v-model="state.url"
|
||||
label="Website URL"
|
||||
placeholder="Enter the website URL to crawl"
|
||||
:message="formErrors.url"
|
||||
:message-type="formErrors.url ? 'error' : 'info'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- PDF Upload (when PDF is selected) -->
|
||||
<div v-if="state.sourceType === 'pdf'" class="flex flex-col gap-2">
|
||||
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
Upload PDF Document
|
||||
</label>
|
||||
|
||||
<!-- File upload area -->
|
||||
<div v-if="!state.selectedFile">
|
||||
<FileUpload
|
||||
accept="application/pdf"
|
||||
:max-size-m-b="10"
|
||||
placeholder="Select a PDF file"
|
||||
upload-text="Click to select PDF or drag and drop"
|
||||
drag-text="Drop PDF file here"
|
||||
@file-selected="handleFileSelected"
|
||||
@file-error="handleFileError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Selected file display -->
|
||||
<div v-else class="p-3 bg-n-alpha-2 rounded-lg border border-n-slate-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-red-600"
|
||||
>
|
||||
<path
|
||||
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{ state.selectedFile.name }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-8">
|
||||
{{
|
||||
Math.round((state.selectedFile.size / 1024 / 1024) * 100) /
|
||||
100
|
||||
}}
|
||||
MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 rounded hover:bg-n-alpha-3 text-n-slate-8 hover:text-n-slate-10"
|
||||
@click="clearSelectedFile"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload error -->
|
||||
<p v-if="uploadError" class="text-sm text-red-600">{{ uploadError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Assistant Selection -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
|
||||
Select Assistant
|
||||
</label>
|
||||
<ComboBox
|
||||
id="assistant"
|
||||
v-model="state.assistantId"
|
||||
:options="assistantList"
|
||||
:has-error="!!formErrors.assistantId"
|
||||
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
|
||||
placeholder="Choose an assistant"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
:message="formErrors.assistantId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||
label="Cancel"
|
||||
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:label="t('CAPTAIN.FORM.CREATE')"
|
||||
label="Create Document"
|
||||
class="w-full"
|
||||
:is-loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
accept: {
|
||||
type: String,
|
||||
default: '*',
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxSizeMB: {
|
||||
type: Number,
|
||||
default: 10,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
uploadText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
dragText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['file-selected', 'file-error']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const fileInput = ref(null);
|
||||
const isDragOver = ref(false);
|
||||
|
||||
const acceptTypes = computed(() => props.accept);
|
||||
const maxSize = computed(() => props.maxSizeMB * 1024 * 1024);
|
||||
|
||||
const handleFileClick = () => {
|
||||
if (!props.disabled) {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const validateFile = file => {
|
||||
// Check file size
|
||||
if (file.size > maxSize.value) {
|
||||
const errorMsg = `File "${file.name}" is too large. Maximum size is ${props.maxSizeMB}MB.`;
|
||||
emit('file-error', errorMsg);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check file type if specified
|
||||
if (
|
||||
props.accept !== '*' &&
|
||||
!file.type.match(new RegExp(props.accept.replace('*', '.*')))
|
||||
) {
|
||||
const errorMsg = `File type not supported. Only ${props.accept} files are allowed.`;
|
||||
emit('file-error', errorMsg);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleFileChange = event => {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (validateFile(file)) {
|
||||
emit('file-selected', file);
|
||||
}
|
||||
}
|
||||
// Reset input value to allow selecting the same file again
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const handleDragOver = event => {
|
||||
event.preventDefault();
|
||||
if (!props.disabled) {
|
||||
isDragOver.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = event => {
|
||||
event.preventDefault();
|
||||
// Only set to false if we're leaving the drop zone completely
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
isDragOver.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = event => {
|
||||
event.preventDefault();
|
||||
isDragOver.value = false;
|
||||
|
||||
if (props.disabled) return;
|
||||
|
||||
const files = event.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (validateFile(file)) {
|
||||
emit('file-selected', file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (isDragOver.value && props.dragText) {
|
||||
return props.dragText;
|
||||
}
|
||||
return props.uploadText || props.placeholder;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="file-upload-zone"
|
||||
:class="{
|
||||
'drag-over': isDragOver,
|
||||
disabled: disabled,
|
||||
}"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop"
|
||||
@click="handleFileClick"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="acceptTypes"
|
||||
:multiple="multiple"
|
||||
:disabled="disabled"
|
||||
class="hidden"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
|
||||
<div class="upload-content">
|
||||
<div class="upload-icon">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-n-slate-8"
|
||||
>
|
||||
<path
|
||||
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="upload-text">
|
||||
<p class="text-sm font-medium text-n-slate-11">
|
||||
{{ displayText }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-8 mt-1">
|
||||
Maximum file size: {{ maxSizeMB }}MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-upload-zone {
|
||||
@apply border-2 border-dashed border-n-slate-6 rounded-lg p-6;
|
||||
@apply bg-n-alpha-2 hover:bg-n-alpha-3 cursor-pointer;
|
||||
@apply transition-all duration-200 ease-in-out;
|
||||
@apply flex flex-col items-center justify-center text-center;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.file-upload-zone:hover {
|
||||
@apply border-n-slate-7;
|
||||
}
|
||||
|
||||
.file-upload-zone.drag-over {
|
||||
@apply border-primary-500 bg-primary-50/50;
|
||||
}
|
||||
|
||||
.file-upload-zone.disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
@apply hover:bg-n-alpha-2 hover:border-n-slate-6;
|
||||
}
|
||||
|
||||
.upload-content {
|
||||
@apply flex flex-col items-center gap-3;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
@apply p-2 rounded-full bg-n-alpha-3;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,24 @@
|
||||
import CaptainDocumentAPI from 'dashboard/api/captain/document';
|
||||
import { createStore } from './storeFactory';
|
||||
|
||||
const actions = mutationTypes => ({
|
||||
async uploadPdf({ commit }, formData) {
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
|
||||
try {
|
||||
const response = await CaptainDocumentAPI.uploadPdf(formData);
|
||||
const { data } = response;
|
||||
commit(mutationTypes.ADD, data.document);
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
|
||||
return Promise.resolve(data);
|
||||
} catch (error) {
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainDocument',
|
||||
API: CaptainDocumentAPI,
|
||||
actions,
|
||||
});
|
||||
|
||||
+5
-1
@@ -63,7 +63,11 @@ Rails.application.routes.draw do
|
||||
resources :copilot_threads, only: [:index, :create] do
|
||||
resources :copilot_messages, only: [:index, :create]
|
||||
end
|
||||
resources :documents, only: [:index, :show, :create, :destroy]
|
||||
resources :documents, only: [:index, :show, :create, :destroy] do
|
||||
collection do
|
||||
post :upload_pdf
|
||||
end
|
||||
end
|
||||
end
|
||||
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
||||
delete :avatar, on: :member
|
||||
|
||||
@@ -5,7 +5,7 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
before_action :set_current_page, only: [:index]
|
||||
before_action :set_documents, except: [:create]
|
||||
before_action :set_document, only: [:show, :destroy]
|
||||
before_action :set_assistant, only: [:create]
|
||||
before_action :set_assistant, only: [:create, :upload_pdf]
|
||||
RESULTS_PER_PAGE = 25
|
||||
|
||||
def index
|
||||
@@ -27,6 +27,35 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def upload_pdf
|
||||
return render_could_not_create_error('Missing Assistant') if @assistant.nil?
|
||||
return render_could_not_create_error('No PDF file provided') unless pdf_file_present?
|
||||
|
||||
# Validate PDF file
|
||||
return render_could_not_create_error('Invalid file type. Only PDF files are allowed.') unless valid_pdf_file?
|
||||
return render_could_not_create_error('File size too large. Maximum size is 10MB.') unless valid_pdf_size?
|
||||
|
||||
# Upload PDF to storage and get URL
|
||||
blob = create_pdf_blob
|
||||
pdf_url = Rails.application.routes.url_helpers.rails_blob_url(blob, host: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'))
|
||||
|
||||
# Create document with PDF URL
|
||||
@document = @assistant.documents.build(
|
||||
name: pdf_params[:pdf_document].original_filename.gsub('.pdf', ''),
|
||||
external_link: pdf_url
|
||||
)
|
||||
@document.save!
|
||||
|
||||
render json: {
|
||||
document: @document.as_json(only: [:id, :name, :status, :created_at]),
|
||||
message: 'PDF uploaded successfully. Processing will begin shortly.'
|
||||
}
|
||||
rescue Captain::Document::LimitExceededError => e
|
||||
render_could_not_create_error(e.message)
|
||||
rescue StandardError => e
|
||||
render_could_not_create_error("PDF upload failed: #{e.message}")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@document.destroy
|
||||
head :no_content
|
||||
@@ -43,7 +72,8 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
end
|
||||
|
||||
def set_assistant
|
||||
@assistant = Current.account.captain_assistants.find_by(id: document_params[:assistant_id])
|
||||
assistant_id = action_name == 'upload_pdf' ? pdf_params[:assistant_id] : document_params[:assistant_id]
|
||||
@assistant = Current.account.captain_assistants.find_by(id: assistant_id)
|
||||
end
|
||||
|
||||
def set_current_page
|
||||
@@ -57,4 +87,32 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
|
||||
def document_params
|
||||
params.require(:document).permit(:name, :external_link, :assistant_id)
|
||||
end
|
||||
|
||||
def pdf_params
|
||||
params.permit(:pdf_document, :assistant_id)
|
||||
end
|
||||
|
||||
def pdf_file_present?
|
||||
pdf_params[:pdf_document].present?
|
||||
end
|
||||
|
||||
def valid_pdf_file?
|
||||
return false unless pdf_params[:pdf_document].respond_to?(:content_type)
|
||||
|
||||
pdf_params[:pdf_document].content_type == 'application/pdf'
|
||||
end
|
||||
|
||||
def valid_pdf_size?
|
||||
return false unless pdf_params[:pdf_document].respond_to?(:size)
|
||||
|
||||
pdf_params[:pdf_document].size <= 10.megabytes
|
||||
end
|
||||
|
||||
def create_pdf_blob
|
||||
ActiveStorage::Blob.create_and_upload!(
|
||||
io: pdf_params[:pdf_document].tempfile,
|
||||
filename: pdf_params[:pdf_document].original_filename,
|
||||
content_type: pdf_params[:pdf_document].content_type
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,4 +22,8 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
def playground?
|
||||
true
|
||||
end
|
||||
|
||||
def upload_pdf?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Api::V1::Accounts::Captain::DocumentsController, type: :controller do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
describe 'POST #upload_pdf' do
|
||||
let(:pdf_file) do
|
||||
fixture_file_upload(
|
||||
Rails.root.join('spec', 'fixtures', 'files', 'sample.pdf'),
|
||||
'application/pdf'
|
||||
)
|
||||
end
|
||||
|
||||
let(:valid_params) do
|
||||
{
|
||||
account_id: account.id,
|
||||
pdf_document: pdf_file,
|
||||
assistant_id: assistant.id
|
||||
}
|
||||
end
|
||||
|
||||
context 'with valid parameters' do
|
||||
it 'creates a new document' do
|
||||
expect do
|
||||
post :upload_pdf, params: valid_params
|
||||
end.to change(Captain::Document, :count).by(1)
|
||||
end
|
||||
|
||||
it 'returns success response' do
|
||||
post :upload_pdf, params: valid_params
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(JSON.parse(response.body)).to have_key('document')
|
||||
expect(JSON.parse(response.body)).to have_key('message')
|
||||
end
|
||||
|
||||
it 'sets document name from filename' do
|
||||
post :upload_pdf, params: valid_params
|
||||
document = Captain::Document.last
|
||||
expect(document.name).to eq('sample')
|
||||
end
|
||||
|
||||
it 'sets document status to in_progress' do
|
||||
post :upload_pdf, params: valid_params
|
||||
document = Captain::Document.last
|
||||
expect(document.status).to eq('in_progress')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
it 'returns error when assistant is missing' do
|
||||
params = valid_params.except(:assistant_id)
|
||||
post :upload_pdf, params: params
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'returns error when PDF file is missing' do
|
||||
params = valid_params.except(:pdf_document)
|
||||
post :upload_pdf, params: params
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'returns error when file is not a PDF' do
|
||||
txt_file = fixture_file_upload(
|
||||
Rails.root.join('spec', 'fixtures', 'files', 'sample.txt'),
|
||||
'text/plain'
|
||||
)
|
||||
params = valid_params.merge(pdf_document: txt_file)
|
||||
post :upload_pdf, params: params
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'returns error when file is too large' do
|
||||
# Mock large file size
|
||||
allow_any_instance_of(ActionDispatch::Http::UploadedFile).to receive(:size).and_return(11.megabytes)
|
||||
post :upload_pdf, params: valid_params
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when document limit is exceeded' do
|
||||
before do
|
||||
allow_any_instance_of(Captain::Document).to receive(:save!).and_raise(
|
||||
Captain::Document::LimitExceededError, 'Document limit exceeded'
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns limit exceeded error' do
|
||||
post :upload_pdf, params: valid_params
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(JSON.parse(response.body)['message']).to include('Document limit exceeded')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -288,4 +288,69 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/:account_id/captain/documents/upload_pdf' do
|
||||
let(:pdf_file) { fixture_file_upload('spec/fixtures/files/sample.pdf', 'application/pdf') }
|
||||
let(:valid_pdf_params) do
|
||||
{
|
||||
pdf_document: pdf_file,
|
||||
assistant_id: assistant.id
|
||||
}
|
||||
end
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
before do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
|
||||
params: valid_pdf_params
|
||||
end
|
||||
|
||||
it 'returns unauthorized status' do
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
|
||||
params: valid_pdf_params,
|
||||
headers: agent.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an admin' do
|
||||
context 'with valid PDF file' do
|
||||
it 'uploads PDF and creates document' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
|
||||
params: valid_pdf_params,
|
||||
headers: admin.create_new_auth_token
|
||||
end.to change(Captain::Document, :count).by(1)
|
||||
end
|
||||
|
||||
it 'returns success status with document data' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
|
||||
params: valid_pdf_params,
|
||||
headers: admin.create_new_auth_token, as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:document]).to be_present
|
||||
expect(json_response[:message]).to eq('PDF uploaded successfully. Processing will begin shortly.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'without PDF file' do
|
||||
before do
|
||||
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
|
||||
params: { assistant_id: assistant.id },
|
||||
headers: admin.create_new_auth_token
|
||||
end
|
||||
|
||||
it 'returns unprocessable entity status' do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Vendored
+53
@@ -0,0 +1,53 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 2 0 R
|
||||
>>
|
||||
endobj
|
||||
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Kids [3 0 R]
|
||||
/Count 1
|
||||
>>
|
||||
endobj
|
||||
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Parent 2 0 R
|
||||
/MediaBox [0 0 612 792]
|
||||
/Contents 4 0 R
|
||||
>>
|
||||
endobj
|
||||
|
||||
4 0 obj
|
||||
<<
|
||||
/Length 44
|
||||
>>
|
||||
stream
|
||||
BT
|
||||
/F1 12 Tf
|
||||
72 720 Td
|
||||
(Sample PDF for testing) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000202 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 5
|
||||
/Root 1 0 R
|
||||
>>
|
||||
startxref
|
||||
294
|
||||
%%EOF
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
This is a sample text file for testing purposes.
|
||||
It contains some basic text content that can be used in tests.
|
||||
@@ -0,0 +1,97 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Documents::CrawlJob, type: :job do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:document) { create(:captain_document, assistant: assistant, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when Firecrawl API key is configured' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: 'test_api_key')
|
||||
end
|
||||
|
||||
it 'calls perform_firecrawl_crawl' do
|
||||
expect_any_instance_of(described_class).to receive(:perform_firecrawl_crawl).with(document)
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
|
||||
it 'handles PDF URLs correctly' do
|
||||
pdf_document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
external_link: 'https://example.com/document.pdf'
|
||||
)
|
||||
|
||||
expect(Captain::Tools::FirecrawlService).to receive_message_chain(:new, :perform)
|
||||
.with(pdf_document.external_link, anything, anything)
|
||||
|
||||
described_class.perform_now(pdf_document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Firecrawl API key is not configured' do
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.destroy
|
||||
end
|
||||
|
||||
it 'calls perform_simple_crawl' do
|
||||
expect_any_instance_of(described_class).to receive(:perform_simple_crawl).with(document)
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'webhook URL generation' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: 'test_api_key')
|
||||
end
|
||||
|
||||
it 'generates correct webhook URL with assistant_id and token' do
|
||||
job = described_class.new
|
||||
webhook_url = job.send(:firecrawl_webhook_url, document)
|
||||
|
||||
expect(webhook_url).to include("assistant_id=#{document.assistant_id}")
|
||||
expect(webhook_url).to include('token=')
|
||||
expect(webhook_url).to include('/enterprise/webhooks/firecrawl')
|
||||
end
|
||||
end
|
||||
|
||||
context 'crawl limit calculation' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_FIRECRAWL_API_KEY', value: 'test_api_key')
|
||||
end
|
||||
|
||||
it 'respects account usage limits' do
|
||||
allow(document.account).to receive(:usage_limits).and_return({
|
||||
captain: {
|
||||
documents: {
|
||||
current_available: 5
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(Captain::Tools::FirecrawlService).to receive_message_chain(:new, :perform)
|
||||
.with(anything, anything, 5)
|
||||
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
|
||||
it 'caps crawl limit at 500' do
|
||||
allow(document.account).to receive(:usage_limits).and_return({
|
||||
captain: {
|
||||
documents: {
|
||||
current_available: 1000
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(Captain::Tools::FirecrawlService).to receive_message_chain(:new, :perform)
|
||||
.with(anything, anything, 500)
|
||||
|
||||
described_class.perform_now(document)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user