Merge branch 'develop' into feat/voice-channel

This commit is contained in:
Sojan Jose
2025-09-02 17:41:28 +02:00
committed by GitHub
9 changed files with 397 additions and 32 deletions

View File

@@ -4,6 +4,10 @@ import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import {
isPdfDocument,
formatDocumentLink,
} from 'shared/helpers/documentHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
@@ -63,6 +67,11 @@ const menuItems = computed(() => {
const createdAt = computed(() => dynamicTime(props.createdAt));
const displayLink = computed(() => formatDocumentLink(props.externalLink));
const linkIcon = computed(() =>
isPdfDocument(props.externalLink) ? 'i-ph-file-pdf' : 'i-ph-link-simple'
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
@@ -71,14 +80,14 @@ const handleAction = ({ action, value }) => {
<template>
<CardLayout>
<div class="flex justify-between w-full gap-1">
<div class="flex gap-1 justify-between w-full">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ name }}
</span>
<div class="flex items-center gap-2">
<div class="flex gap-2 items-center">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
class="flex relative items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
@@ -90,26 +99,26 @@ const handleAction = ({ action, value }) => {
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
class="top-full mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0"
@action="handleAction($event)"
/>
</div>
</div>
</div>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex gap-4 justify-between items-center w-full">
<span
class="text-sm shrink-0 truncate text-n-slate-11 flex items-center gap-1"
class="flex gap-1 items-center text-sm truncate shrink-0 text-n-slate-11"
>
<i class="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<span
class="text-n-slate-11 text-sm truncate flex justify-start flex-1 items-center gap-1"
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11"
>
<i class="i-ph-link-simple shrink-0" />
<span class="truncate">{{ externalLink }}</span>
<i :class="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</span>
<div class="shrink-0 text-sm text-n-slate-11 line-clamp-1">
<div class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
{{ createdAt }}
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DocumentForm from './DocumentForm.vue';
@@ -12,7 +13,6 @@ const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const documentForm = ref(null);
const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
@@ -23,7 +23,7 @@ const handleSubmit = async newDocument => {
dialogRef.value.close();
} catch (error) {
const errorMessage =
error?.response?.message || t(`${i18nKey}.ERROR_MESSAGE`);
parseAPIErrorResponse(error) || t(`${i18nKey}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
@@ -48,11 +48,7 @@ defineExpose({ dialogRef });
:show-confirm-button="false"
@close="handleClose"
>
<DocumentForm
ref="documentForm"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<DocumentForm @submit="handleSubmit" @cancel="handleCancel" />
<template #footer />
</Dialog>
</template>

View File

@@ -1,9 +1,10 @@
<script setup>
import { reactive, computed } from 'vue';
import { reactive, computed, ref, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, url } from '@vuelidate/validators';
import { required, minLength, requiredIf, url } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -11,6 +12,8 @@ import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const emit = defineEmits(['submit', 'cancel']);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const { t } = useI18n();
const formState = {
@@ -20,14 +23,25 @@ const formState = {
const initialState = {
name: '',
url: '',
assistantId: null,
documentType: 'url',
pdfFile: null,
};
const state = reactive({ ...initialState });
const fileInputRef = ref(null);
const validationRules = {
url: { required, url, minLength: minLength(1) },
url: {
required: requiredIf(() => state.documentType === 'url'),
url: requiredIf(() => state.documentType === 'url' && url),
minLength: requiredIf(() => state.documentType === 'url' && minLength(1)),
},
assistantId: { required },
pdfFile: {
required: requiredIf(() => state.documentType === 'pdf'),
},
};
const assistantList = computed(() =>
@@ -37,10 +51,17 @@ const assistantList = computed(() =>
}))
);
const documentTypeOptions = [
{ value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') },
{ value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') },
];
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const hasPdfFileError = computed(() => v$.value.pdfFile.$error);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.DOCUMENTS.FORM.${errorKey}.ERROR`)
@@ -50,14 +71,57 @@ const getErrorMessage = (field, errorKey) => {
const formErrors = computed(() => ({
url: getErrorMessage('url', 'URL'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'),
}));
const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => ({
external_link: state.url,
assistant_id: state.assistantId,
const handleFileChange = event => {
const file = event.target.files[0];
if (file) {
if (file.type !== 'application/pdf') {
useAlert(t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.INVALID_TYPE'));
event.target.value = '';
return;
}
if (file.size > MAX_FILE_SIZE) {
// 10MB
useAlert(t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.TOO_LARGE'));
event.target.value = '';
return;
}
state.pdfFile = file;
state.name = file.name.replace(/\.pdf$/i, '');
}
};
const openFileDialog = () => {
// Use nextTick to ensure the ref is available
nextTick(() => {
if (fileInputRef.value) {
fileInputRef.value.click();
}
});
};
const prepareDocumentDetails = () => {
const formData = new FormData();
formData.append('document[assistant_id]', state.assistantId);
if (state.documentType === 'url') {
formData.append('document[external_link]', state.url);
formData.append('document[name]', state.name || state.url);
} else {
formData.append('document[pdf_file]', state.pdfFile);
formData.append(
'document[name]',
state.name || state.pdfFile.name.replace('.pdf', '')
);
// No need to send external_link for PDF - it's auto-generated in the backend
}
return formData;
};
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
@@ -71,13 +135,89 @@ const handleSubmit = async () => {
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="flex flex-col gap-1">
<label
for="documentType"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ t('CAPTAIN.DOCUMENTS.FORM.TYPE.LABEL') }}
</label>
<ComboBox
id="documentType"
v-model="state.documentType"
:options="documentTypeOptions"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-if="state.documentType === 'url'"
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'"
/>
<div v-if="state.documentType === 'pdf'" class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.LABEL') }}
</label>
<div class="relative">
<input
ref="fileInputRef"
type="file"
accept=".pdf"
class="hidden"
@change="handleFileChange"
/>
<Button
type="button"
:color="hasPdfFileError ? 'ruby' : 'slate'"
:variant="hasPdfFileError ? 'outline' : 'solid'"
class="!w-full !h-auto !justify-between !py-4"
@click="openFileDialog"
>
<template #default>
<div class="flex gap-2 items-center">
<div
class="flex justify-center items-center w-10 h-10 rounded-lg bg-n-slate-3"
>
<i class="text-xl i-ph-file-pdf text-n-slate-11" />
</div>
<div class="flex flex-col flex-1 gap-1 items-start">
<p class="m-0 text-sm font-medium text-n-slate-12">
{{
state.pdfFile
? state.pdfFile.name
: t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.CHOOSE_FILE')
}}
</p>
<p class="m-0 text-xs text-n-slate-11">
{{
state.pdfFile
? `${(state.pdfFile.size / 1024 / 1024).toFixed(2)} MB`
: t('CAPTAIN.DOCUMENTS.FORM.PDF_FILE.HELP_TEXT')
}}
</p>
</div>
</div>
<i class="i-lucide-upload text-n-slate-11" />
</template>
</Button>
</div>
<p v-if="formErrors.pdfFile" class="text-xs text-n-ruby-9">
{{ formErrors.pdfFile }}
</p>
</div>
<Input
v-model="state.name"
:label="t('CAPTAIN.DOCUMENTS.FORM.NAME.LABEL')"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')"
/>
<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') }}
@@ -88,12 +228,12 @@ const handleSubmit = async () => {
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
class="[&>div>button]:bg-n-alpha-black2"
:message="formErrors.assistantId"
/>
</div>
<div class="flex items-center justify-between w-full gap-3">
<div class="flex gap-3 justify-between items-center w-full">
<Button
type="button"
variant="faded"

View File

@@ -96,8 +96,12 @@ watch(
:label="selectedLabel"
trailing-icon
:disabled="disabled"
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 [&:not(.focused)]:hover:enabled:outline-n-slate-6 [&:not(.focused)]:dark:hover:enabled:outline-n-slate-6 [&:not(.focused)]:dark:outline-n-weak focus:outline-n-brand"
:class="{ focused: open }"
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
:class="{
focused: open,
'[&:not(.focused)]:dark:outline-n-weak [&:not(.focused)]:hover:enabled:outline-n-slate-6 [&:not(.focused)]:dark:hover:enabled:outline-n-slate-6':
!hasError,
}"
:icon="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
@click="toggleDropdown"
/>

View File

@@ -807,6 +807,58 @@
"ERROR_MESSAGE": "Unable to update portal"
}
}
},
"PDF_UPLOAD": {
"TITLE": "Upload PDF Document",
"DESCRIPTION": "Upload a PDF document to automatically generate FAQs using AI",
"DRAG_DROP_TEXT": "Drag and drop your PDF file here, or click to select",
"SELECT_FILE": "Select PDF File",
"ADDITIONAL_CONTEXT_LABEL": "Additional Context (Optional)",
"ADDITIONAL_CONTEXT_PLACEHOLDER": "Provide any additional context or instructions for FAQ generation...",
"UPLOADING": "Uploading...",
"UPLOAD": "Upload & Process",
"CANCEL": "Cancel",
"ERROR_INVALID_TYPE": "Please select a valid PDF file",
"ERROR_FILE_TOO_LARGE": "File size must be less than 512MB",
"ERROR_UPLOAD_FAILED": "Failed to upload PDF. Please try again."
},
"PDF_DOCUMENTS": {
"TITLE": "PDF Documents",
"DESCRIPTION": "Manage uploaded PDF documents and generate FAQs from them",
"UPLOAD_PDF": "Upload PDF",
"UPLOAD_FIRST_PDF": "Upload your first PDF",
"UPLOADED_BY": "Uploaded by",
"GENERATE_FAQS": "Generate FAQs",
"GENERATING": "Generating...",
"CONFIRM_DELETE": "Are you sure you want to delete {filename}?",
"EMPTY_STATE": {
"TITLE": "No PDF documents yet",
"DESCRIPTION": "Upload PDF documents to automatically generate FAQs using AI"
},
"STATUS": {
"UPLOADED": "Ready",
"PROCESSING": "Processing",
"PROCESSED": "Completed",
"FAILED": "Failed"
}
},
"CONTENT_GENERATION": {
"TITLE": "Content Generation",
"DESCRIPTION": "Upload PDF documents to automatically generate FAQ content using AI",
"UPLOAD_TITLE": "Upload PDF Document",
"DRAG_DROP": "Drag and drop your PDF file here, or click to select",
"SELECT_FILE": "Select PDF File",
"UPLOADING": "Processing document...",
"UPLOAD_SUCCESS": "Document processed successfully!",
"UPLOAD_ERROR": "Failed to upload document. Please try again.",
"INVALID_FILE_TYPE": "Please select a valid PDF file",
"FILE_TOO_LARGE": "File size must be less than 512MB",
"GENERATED_CONTENT": "Generated FAQ Content",
"PUBLISH_SELECTED": "Publish Selected",
"PUBLISHING": "Publishing...",
"FROM_DOCUMENT": "From document",
"NO_CONTENT": "No generated content available. Upload a PDF document to get started.",
"LOADING": "Loading generated content..."
}
}
}

View File

@@ -701,11 +701,28 @@
"ERROR_MESSAGE": "There was an error creating the document, please try again."
},
"FORM": {
"TYPE": {
"LABEL": "Document Type",
"URL": "URL",
"PDF": "PDF File"
},
"URL": {
"LABEL": "URL",
"PLACEHOLDER": "Enter the URL of the document",
"ERROR": "Please provide a valid URL for the document"
},
"PDF_FILE": {
"LABEL": "PDF File",
"CHOOSE_FILE": "Choose PDF file",
"ERROR": "Please select a PDF file",
"HELP_TEXT": "Maximum file size: 10MB",
"INVALID_TYPE": "Please select a valid PDF file",
"TOO_LARGE": "File size exceeds 10MB limit"
},
"NAME": {
"LABEL": "Document Name (Optional)",
"PLACEHOLDER": "Enter a name for the document"
},
"ASSISTANT": {
"LABEL": "Assistant",
"PLACEHOLDER": "Select the assistant",

View File

@@ -62,9 +62,7 @@ export default {
</script>
<template>
<div
class="border border-n-weak bg-n-background h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<div class="h-full p-6 w-full max-w-full flex-shrink-0 flex-grow-0">
<div class="flex flex-col items-center justify-start h-full text-center">
<div v-if="hasError" class="max-w-lg mx-auto text-center">
<h5>{{ errorStateMessage }}</h5>
@@ -75,7 +73,7 @@ export default {
</div>
<div
v-else
class="flex flex-col items-center justify-center px-8 py-10 text-center shadow rounded-3xl outline outline-1 outline-n-weak"
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
>
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}

View File

@@ -0,0 +1,38 @@
/**
* Document Helper - utilities for document display and formatting
*/
// Constants for document processing
const PDF_PREFIX = 'PDF:';
const TIMESTAMP_PATTERN = /_\d{14}(?=\.pdf$)/; // Format: _YYYYMMDDHHMMSS before .pdf extension
/**
* Checks if a document is a PDF based on its external link
* @param {string} externalLink - The external link string
* @returns {boolean} True if the document is a PDF
*/
export const isPdfDocument = externalLink => {
if (!externalLink) return false;
return externalLink.startsWith(PDF_PREFIX);
};
/**
* Formats the display link for documents
* For PDF documents: removes 'PDF:' prefix and timestamp suffix
* For regular URLs: returns as-is
*
* @param {string} externalLink - The external link string
* @returns {string} Formatted display link
*/
export const formatDocumentLink = externalLink => {
if (!externalLink) return '';
if (isPdfDocument(externalLink)) {
// Remove 'PDF:' prefix
const fullName = externalLink.substring(PDF_PREFIX.length);
// Remove timestamp suffix if present
return fullName.replace(TIMESTAMP_PATTERN, '');
}
return externalLink;
};

View File

@@ -0,0 +1,111 @@
import {
isPdfDocument,
formatDocumentLink,
} from 'shared/helpers/documentHelper';
describe('documentHelper', () => {
describe('#isPdfDocument', () => {
it('returns true for PDF documents', () => {
expect(isPdfDocument('PDF:document.pdf')).toBe(true);
expect(isPdfDocument('PDF:my-file_20241227123045.pdf')).toBe(true);
expect(isPdfDocument('PDF:report with spaces_20241227123045.pdf')).toBe(
true
);
});
it('returns false for regular URLs', () => {
expect(isPdfDocument('https://example.com')).toBe(false);
expect(isPdfDocument('http://docs.example.com/file.pdf')).toBe(false);
expect(isPdfDocument('ftp://files.example.com/document.pdf')).toBe(false);
});
it('returns false for empty or null values', () => {
expect(isPdfDocument('')).toBe(false);
expect(isPdfDocument(null)).toBe(false);
expect(isPdfDocument(undefined)).toBe(false);
});
it('returns false for strings that contain PDF but do not start with PDF:', () => {
expect(isPdfDocument('document PDF:file.pdf')).toBe(false);
expect(isPdfDocument('My PDF:file.pdf')).toBe(false);
});
});
describe('#formatDocumentLink', () => {
describe('PDF documents', () => {
it('removes PDF: prefix from PDF documents', () => {
expect(formatDocumentLink('PDF:document.pdf')).toBe('document.pdf');
expect(formatDocumentLink('PDF:my-file.pdf')).toBe('my-file.pdf');
});
it('removes timestamp suffix from PDF documents', () => {
expect(formatDocumentLink('PDF:document_20241227123045.pdf')).toBe(
'document.pdf'
);
expect(formatDocumentLink('PDF:report_20231215094530.pdf')).toBe(
'report.pdf'
);
});
it('handles PDF documents with spaces in filename', () => {
expect(formatDocumentLink('PDF:my document_20241227123045.pdf')).toBe(
'my document.pdf'
);
expect(
formatDocumentLink('PDF:Annual Report 2024_20241227123045.pdf')
).toBe('Annual Report 2024.pdf');
});
it('handles PDF documents without timestamp suffix', () => {
expect(formatDocumentLink('PDF:document.pdf')).toBe('document.pdf');
expect(formatDocumentLink('PDF:simple-file.pdf')).toBe(
'simple-file.pdf'
);
});
it('handles PDF documents with partial timestamp patterns', () => {
expect(formatDocumentLink('PDF:document_202412.pdf')).toBe(
'document_202412.pdf'
);
expect(formatDocumentLink('PDF:file_123.pdf')).toBe('file_123.pdf');
});
it('handles edge cases with timestamp pattern', () => {
expect(
formatDocumentLink('PDF:doc_20241227123045_final_20241227123045.pdf')
).toBe('doc_20241227123045_final.pdf');
});
});
describe('Regular URLs', () => {
it('returns regular URLs unchanged', () => {
expect(formatDocumentLink('https://example.com')).toBe(
'https://example.com'
);
expect(formatDocumentLink('http://docs.example.com/api')).toBe(
'http://docs.example.com/api'
);
expect(formatDocumentLink('https://github.com/user/repo')).toBe(
'https://github.com/user/repo'
);
});
it('handles URLs with query parameters', () => {
expect(formatDocumentLink('https://example.com?param=value')).toBe(
'https://example.com?param=value'
);
expect(
formatDocumentLink(
'https://api.example.com/docs?version=v1&format=json'
)
).toBe('https://api.example.com/docs?version=v1&format=json');
});
it('handles URLs with fragments', () => {
expect(formatDocumentLink('https://example.com/docs#section1')).toBe(
'https://example.com/docs#section1'
);
});
});
});
});