feat: support uploading pdf in document knowledgebase

This commit is contained in:
Tanmay Deep Sharma
2025-06-26 02:41:19 +05:30
parent c42dd8a23e
commit ea3a84c930
14 changed files with 864 additions and 35 deletions
@@ -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();
@@ -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"
@@ -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,
});