diff --git a/src/containers/Assistants/AssistantDetail/ConfigEditor.test.tsx b/src/containers/Assistants/AssistantDetail/ConfigEditor.test.tsx index e87646a21..08f30283c 100644 --- a/src/containers/Assistants/AssistantDetail/ConfigEditor.test.tsx +++ b/src/containers/Assistants/AssistantDetail/ConfigEditor.test.tsx @@ -259,7 +259,7 @@ describe('ConfigEditor — settings parsing', () => { }, }); - expect(screen.getByRole('sliderDisplay')).toHaveValue(0.7); + expect(screen.getByTestId('sliderDisplay')).toHaveValue(0.7); }); it('falls back to default model (gpt-4o) when version.model is null', () => { @@ -271,7 +271,7 @@ describe('ConfigEditor — settings parsing', () => { it('falls back to temperature 0.1 when settings is null', () => { renderEdit({ version: { ...mockVersion, settings: null } }); - expect(screen.getByRole('sliderDisplay')).toHaveValue(0.1); + expect(screen.getByTestId('sliderDisplay')).toHaveValue(0.1); }); }); diff --git a/src/containers/Assistants/AssistantDetail/ConfigEditor.tsx b/src/containers/Assistants/AssistantDetail/ConfigEditor.tsx index 6706435d2..a8b854fed 100644 --- a/src/containers/Assistants/AssistantDetail/ConfigEditor.tsx +++ b/src/containers/Assistants/AssistantDetail/ConfigEditor.tsx @@ -16,7 +16,7 @@ import { GET_ASSISTANT, GET_ASSISTANT_VERSIONS } from 'graphql/queries/Assistant import ExpandIcon from 'assets/images/icons/ExpandContent.svg?react'; -import { AssistantOptions } from '../AssistantOptions/AssistantOptions'; +import { KnowledgeBaseOptions } from '../AssistantOptions/KnowledgeBaseOptions'; import type { AssistantVersion } from '../VersionPanel/VersionPanel'; import styles from './ConfigEditor.module.css'; @@ -249,7 +249,7 @@ export const ConfigEditor = ({ disabled: newVersionInProgress, }, { - component: AssistantOptions, + component: KnowledgeBaseOptions, name: 'assistantOptions', formikValues: formik.values, setFieldValue: formik.setFieldValue, @@ -342,7 +342,7 @@ export const ConfigEditor = ({ data-testid="saveVersionButton" onClick={formik.submitForm} loading={savingChanges || creating} - disabled={newVersionInProgress || savingChanges || creating} + disabled={newVersionInProgress || savingChanges || creating || !hasUnsavedChanges} > {t('Save')} @@ -363,11 +363,6 @@ export const ConfigEditor = ({ ))} - {!createMode && ( - - {t('No evals run. Start New Eval >')} - - )} diff --git a/src/containers/Assistants/AssistantList/AssistantList.module.css b/src/containers/Assistants/AssistantList/AssistantList.module.css index 357dad4f3..9cfee1be9 100644 --- a/src/containers/Assistants/AssistantList/AssistantList.module.css +++ b/src/containers/Assistants/AssistantList/AssistantList.module.css @@ -26,11 +26,27 @@ color: #1f2937; } +.DisplayIdRow { + display: flex; + align-items: center; + gap: 2px; +} + .DisplayId { font-size: 0.75rem; color: #6b7280; } +.CopyButton { + padding: 2px !important; + color: #6b7280; + + svg { + width: 12px; + height: 12px; + } +} + .VersionBadge { color: #374151; } diff --git a/src/containers/Assistants/AssistantList/AssistantList.test.tsx b/src/containers/Assistants/AssistantList/AssistantList.test.tsx index 6fb10c5b8..1c371b48b 100644 --- a/src/containers/Assistants/AssistantList/AssistantList.test.tsx +++ b/src/containers/Assistants/AssistantList/AssistantList.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router'; import * as Notification from 'common/notification'; +import * as Utils from 'common/utils'; import { cloneAssistantFromListMock, @@ -15,6 +16,7 @@ import { countAssistantsMock, filterAssistantsMock, filterAssistantsAfterCloneMock, + removeAssistant, } from 'mocks/Assistants'; import AssistantList from './AssistantList'; @@ -285,3 +287,37 @@ test('polling stays silent while cloneStatus is in_progress', async () => { expect(notificationSpy).not.toHaveBeenCalledWith('Assistant cloned successfully'); expect(notificationSpy).not.toHaveBeenCalledWith('Assistant clone failed', 'warning'); }); + +test('clicking copy button in name cell calls copyToClipboard with assistantDisplayId', async () => { + const copySpy = vi.spyOn(Utils, 'copyToClipboard').mockImplementation(() => {}); + renderAssistantList(); + + await waitFor(() => { + expect(screen.getAllByTestId('copyAssistantId')).toHaveLength(2); + }); + + fireEvent.click(screen.getAllByTestId('copyAssistantId')[0]); + + expect(copySpy).toHaveBeenCalledWith('asst_abc123'); + copySpy.mockRestore(); +}); + +test('delete assistant calls deleteModifier with deleteAssistantId', async () => { + renderAssistantList([filterAssistantsMock, countAssistantsMock, removeAssistant]); + + await waitFor(() => { + expect(screen.getAllByTestId('DeleteIcon')).toHaveLength(2); + }); + + fireEvent.click(screen.getAllByTestId('DeleteIcon')[0]); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('Confirm')); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/src/containers/Assistants/AssistantList/AssistantList.tsx b/src/containers/Assistants/AssistantList/AssistantList.tsx index ae4953b1c..b2e3d4c27 100644 --- a/src/containers/Assistants/AssistantList/AssistantList.tsx +++ b/src/containers/Assistants/AssistantList/AssistantList.tsx @@ -5,9 +5,12 @@ import { useApolloClient, useMutation, useQuery } from '@apollo/client'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; +import { IconButton, Tooltip } from '@mui/material'; + import EditIcon from 'assets/images/icons/Edit.svg?react'; import CopyIcon from 'assets/images/icons/Settings/Copy.svg?react'; +import { copyToClipboard } from 'common/utils'; import { FILTER_ASSISTANTS, GET_ASSISTANT, GET_ASSISTANTS_COUNT } from 'graphql/queries/Assistant'; import { CLONE_ASSISTANT, DELETE_ASSISTANT } from 'graphql/mutations/Assistant'; import { List } from 'containers/List/List'; @@ -22,7 +25,22 @@ dayjs.extend(relativeTime); const getAssistantName = (name: string, assistantDisplayId: string) => (
{name} - {assistantDisplayId} + + + { + e.stopPropagation(); + copyToClipboard(assistantDisplayId); + }} + data-testid="copyAssistantId" + > + + + + {assistantDisplayId} +
); @@ -127,9 +145,9 @@ export const AssistantList = () => { }); const columnNames = [ - { name: 'name', label: t('Assistant Name') }, + { label: t('Assistant Name') }, { label: t('Live Version') }, - { label: t('Last Updated') }, + { name: 'updated_at', label: t('Last Updated'), sort: true, order: 'desc' }, { label: t('Actions') }, ]; @@ -165,7 +183,7 @@ export const AssistantList = () => { dialogMessage={t("You won't be able to use this assistant.")} {...queries} {...columnAttributes} - searchParameter={['name']} + searchParameter={['name_or_assistant_id']} additionalAction={additionalAction} button={{ show: true, @@ -173,6 +191,8 @@ export const AssistantList = () => { action: () => navigate('/assistants/add'), }} editSupport={false} + deleteModifier={{ variables: (id: string) => ({ deleteAssistantId: id }) }} + sortConfig={{ sortBy: 'updated_at', sortOrder: 'desc' }} /> {cloneDialogOpen && selectedAssistant && ( diff --git a/src/containers/Assistants/AssistantOptions/AssistantOptions.module.css b/src/containers/Assistants/AssistantOptions/AssistantOptions.module.css index a03603159..445308ecc 100644 --- a/src/containers/Assistants/AssistantOptions/AssistantOptions.module.css +++ b/src/containers/Assistants/AssistantOptions/AssistantOptions.module.css @@ -82,129 +82,26 @@ column-gap: 0.5rem; } -:global .kb-dialog-content { - padding: 4px 32px 16px 32px !important; - padding-top: 4px !important; -} - -:global(.MuiDialog-paper):has(.kb-dialog-content) { - width: 663px !important; - max-width: 663px !important; - min-width: 663px !important; - min-height: 510px; - border-radius: 16px !important; - border: 1px solid #cccccc; - box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.08) !important; - margin: 0 !important; -} - -:global(.MuiDialog-scrollPaper):has(.kb-dialog-content), -:global(.MuiDialog-container):has(.kb-dialog-content) { - align-items: center !important; -} - -:global(.MuiDialog-paper):has(.kb-dialog-content) :global(.MuiDialogTitle-root) { - font-weight: 600 !important; - font-size: 24px !important; - color: #191c1a !important; - text-align: left !important; - padding: 20px 32px 0px 32px !important; -} - -:global(.MuiDialog-paper):has(.kb-dialog-content) :global(.MuiDialogActions-root) { - padding: 16px 32px 28px 32px !important; - gap: 12px; -} - -:global(.MuiDialog-paper):has(.kb-dialog-content) :global(.MuiDialogActions-root button) { - border-radius: 100px !important; - height: 40px; - min-width: 91px; - font-weight: 600; - font-size: 14px; - text-transform: none !important; -} - .DialogContent { + text-align: center; + max-height: 40vh; display: flex; flex-direction: column; } -.DialogSubtitle { - font-size: 12px; - color: #555555; - margin: 2px 0 12px 0; - line-height: 20px; -} - -.DialogBody { - display: flex; - gap: 24px; - min-height: 300px; -} - -.FileSection { - flex: 1; +.UploadInfo { + font-size: 0.8rem; + line-height: 0; + color: #555; display: flex; + gap: 1.2rem; flex-direction: column; - min-width: 0; -} - -.FileSectionHeader { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.FileSectionTitle { - font-size: 14px; - font-weight: 600; - color: #2f2f2f; -} - -.AddFilesButton { - border-radius: 100px !important; - font-size: 14px !important; - font-weight: 500 !important; - height: 32px; - padding: 0 16px 0 12px !important; - border: 2px solid #119656 !important; - border-color: #119656 !important; - color: #119656 !important; - display: flex; - align-items: center; - gap: 4px; - text-transform: none !important; + margin-top: 1rem; } -.InfoSection { - width: 168px; - min-width: 168px; - font-size: 12px; - color: #555555; - opacity: 0.7; - padding-top: 4px; -} - -.InfoText { - line-height: 18px; - margin: 0 0 16px 0; -} - -.InfoLink { +.DialogContent span a { color: #119656; font-weight: 600; - text-decoration: underline; - opacity: 1; -} - -.FileLimitText { - margin: 0; - font-size: 12px; - font-weight: 700; - color: #555555; - line-height: 18px; } .UploadContainer { @@ -240,19 +137,18 @@ display: flex; flex-direction: column; row-gap: 0.5rem; + margin: 1rem 0; height: 100%; - overflow-y: auto; - max-height: 240px; + overflow-y: scroll; } .File { display: flex; align-items: center; column-gap: 0.5rem; - font-size: 0.85rem; - background-color: #f0f4f2; - padding: 0.6rem 0.75rem; - border-radius: 8px; + font-size: 0.8rem; + background-color: #ebf8ee; + padding: 0.5rem 0.5rem; } .FileLeadingIcon { @@ -308,14 +204,6 @@ padding: 4px; } -.DeleteButton { - padding: 4px; -} - -.DeleteIcon { - color: #e53935 !important; -} - .RetryIcon { color: #76848f; } diff --git a/src/containers/Assistants/AssistantOptions/AssistantOptions.test.tsx b/src/containers/Assistants/AssistantOptions/AssistantOptions.test.tsx index f774f7b2b..5c171732f 100644 --- a/src/containers/Assistants/AssistantOptions/AssistantOptions.test.tsx +++ b/src/containers/Assistants/AssistantOptions/AssistantOptions.test.tsx @@ -490,7 +490,7 @@ describe('AssistantOptions upload queue behavior', () => { ); }); - fireEvent.click(screen.getByRole('button', { name: 'Proceed' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => { expect(setNotificationSpy).toHaveBeenCalledWith( diff --git a/src/containers/Assistants/AssistantOptions/AssistantOptions.tsx b/src/containers/Assistants/AssistantOptions/AssistantOptions.tsx index 613dac6f8..8d2dbafad 100644 --- a/src/containers/Assistants/AssistantOptions/AssistantOptions.tsx +++ b/src/containers/Assistants/AssistantOptions/AssistantOptions.tsx @@ -1,7 +1,6 @@ import { useMutation } from '@apollo/client'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import ReplayIcon from '@mui/icons-material/Replay'; import { Button, CircularProgress, IconButton, Slider, Tooltip, Typography } from '@mui/material'; @@ -11,6 +10,8 @@ import { useTranslation } from 'react-i18next'; import AddIcon from 'assets/images/AddGreenIcon.svg?react'; import DatabaseIcon from 'assets/images/database.svg?react'; import FileIcon from 'assets/images/FileGreen.svg?react'; +import CrossIcon from 'assets/images/icons/Cross.svg?react'; +import UploadIcon from 'assets/images/icons/UploadIcon.svg?react'; import { setErrorMessage, setNotification } from 'common/notification'; import { DialogBox } from 'components/UI/DialogBox/DialogBox'; @@ -501,16 +502,14 @@ export const AssistantOptions = ({ dialog = (
{isLegacyVectorStore ? ( @@ -520,122 +519,93 @@ export const AssistantOptions = ({ re-upload the files in the new assistant.

) : ( - <> -

{t('You are adding more files to existing Knowledge Base')}

-
-
-
- {t('Selected File(s)')} - -
+ + )} -
- {getSortedFiles(files).map((file, index) => ( -
-
- {file.status === 'uploading' ? ( - - ) : ( - - )} -
-
{file.filename}
-
-
- {file.sourceFile && ( - <> - {file.status === 'queued' && ( - - )} - {file.status === 'failed' && ( - <> - - - - - - handleRetryFile(file)} - > - - - - - - )} - {file.status === 'attached' && ( - - )} - + {files.length > 0 && ( +
+ {getSortedFiles(files).map((file, index) => ( +
+
+ +
+
{file.filename}
+ {!isLegacyVectorStore && ( +
+
+ {file.status === 'failed' && ( + + + handleRetryFile(file)} + > + + + + + )} +
+
+ {file.sourceFile && ( + <> + {file.status === 'uploading' && } + {file.status === 'queued' && ( + + )} + {file.status === 'failed' && ( + + + )} -
-
- handleRemoveFile(file)} - > - - -
-
+ {file.status === 'attached' && ( + + )} + + )}
- ))} -
-
- -
-

- Information in the attached files will be available to this assistant.{' '} - - Allowed file formats - -

-

- Individual File Limit: {MAX_FILE_SIZE_MB}MB -

+
+ handleRemoveFile(file)} + > + + +
+
+ )}
-
- + ))} +
)} +
+ Individual file size limit: 20MB + Allowed file formats: .csv, .doc, .docx, .html, .java, .md, .pdf, .pptx, .txt + Each file takes approx 15 secs to upload. +
); @@ -711,7 +681,7 @@ export const AssistantOptions = ({ disabled={disabled} /> + render( + + + + ); + +describe('KnowledgeBaseOptions', () => { + test('shows Manage Knowledge Base dialog title when opened', async () => { + renderKnowledgeBaseOptions(); + + fireEvent.click(screen.getByTestId('addFiles')); + + await waitFor(() => { + expect(screen.getByText('Manage Knowledge Base')).toBeInTheDocument(); + }); + }); + + test('shows dialog subtitle when not a legacy vector store', async () => { + renderKnowledgeBaseOptions(); + + fireEvent.click(screen.getByTestId('addFiles')); + + await waitFor(() => { + expect( + screen.getByText('You are adding more files to existing Knowledge Base') + ).toBeInTheDocument(); + }); + }); + + test('shows Proceed button in dialog', async () => { + renderKnowledgeBaseOptions(); + + fireEvent.click(screen.getByTestId('addFiles')); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Proceed' })).toBeInTheDocument(); + }); + }); + + test('shows Add Files button inside dialog', async () => { + renderKnowledgeBaseOptions(); + + fireEvent.click(screen.getByTestId('addFiles')); + + await waitFor(() => { + expect(screen.getByText('Add Files')).toBeInTheDocument(); + }); + }); + + test('shows knowledgeBaseName as fallback when vectorStoreId is empty', async () => { + const props = { + ...baseProps, + vectorStoreId: '', + formikValues: { + ...baseProps.formikValues, + knowledgeBaseVersionId: 'kbv-1', + knowledgeBaseName: 'My Knowledge Base', + }, + }; + + renderKnowledgeBaseOptions(props); + + await waitFor(() => { + expect(screen.getByText('My Knowledge Base')).toBeInTheDocument(); + }); + }); + + test('shows vectorStoreId when vectorStoreId is provided', async () => { + const props = { + ...baseProps, + vectorStoreId: 'vs_abc123', + formikValues: { + ...baseProps.formikValues, + knowledgeBaseVersionId: 'kbv-1', + knowledgeBaseName: 'My Knowledge Base', + }, + }; + + renderKnowledgeBaseOptions(props); + + await waitFor(() => { + expect(screen.getByText('vs_abc123')).toBeInTheDocument(); + }); + }); + + test('shows read-only note for legacy vector store inside dialog', async () => { + const props = { + ...baseProps, + isLegacyVectorStore: true, + }; + + renderKnowledgeBaseOptions(props); + + fireEvent.click(screen.getByTestId('addFiles')); + + await waitFor(() => { + expect(screen.getByTestId('readOnlyNote')).toBeInTheDocument(); + }); + }); +}); + +describe('KnowledgeBaseOptions upload queue behavior', () => { + test('uploads at most 10 files concurrently and starts next queued file on completion', async () => { + const selectedFiles = Array.from({ length: 12 }, (_, index) => { + return new File(['content'], `queue-file-${index}.txt`, { type: 'text/plain' }); + }); + + const mocks = [ + ...Array.from({ length: 10 }, (_, index) => + createUploadSuccessMock(`queue-file-${index}.txt`, index === 0 ? 500 : Infinity) + ), + createUploadSuccessMock('queue-file-10.txt', Infinity), + createUploadSuccessMock('queue-file-11.txt', Infinity), + ]; + + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: selectedFiles, + }, + }); + + await waitFor(() => { + expect(screen.getAllByTestId('uploadingIcon').length).toBe(10); + expect(screen.getAllByTestId('queuedIcon').length).toBe(2); + }); + + await waitFor( + () => { + expect(screen.getAllByTestId('uploadingIcon').length).toBe(10); + expect(screen.getAllByTestId('queuedIcon').length).toBe(1); + }, + { timeout: 1000 } + ); + }); + + test('does not enqueue new uploads after closing dialog', async () => { + const selectedFiles = Array.from({ length: 12 }, (_, index) => { + return new File(['content'], `cancel-file-${index}.txt`, { type: 'text/plain' }); + }); + + const mocks = [ + createUploadSuccessMock('cancel-file-0.txt', 200), + ...Array.from({ length: 11 }, (_, idx) => createUploadSuccessMock(`cancel-file-${idx + 1}.txt`, Infinity)), + ]; + + render( + + + + ); + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { files: selectedFiles }, + }); + + await waitFor(() => { + expect(screen.getAllByTestId('queuedIcon').length).toBeGreaterThan(0); + }); + + fireEvent.click(screen.getByTestId('cancel-button')); + + await waitFor(() => { + expect(screen.queryByTestId('uploadFile')).not.toBeInTheDocument(); + }); + + expect(screen.queryByTestId('uploadFile')).not.toBeInTheDocument(); + expect(screen.queryByTestId('uploadingIcon')).not.toBeInTheDocument(); + expect(screen.queryAllByTestId('queuedIcon').length).toBeLessThanOrEqual(11); + }); + + test('orders file rows with queued first, then failed, and completed at the bottom', async () => { + const files = Array.from( + { length: 12 }, + (_, index) => new File(['content'], `order-file-${index}.txt`, { type: 'text/plain' }) + ); + + const mocks = Array.from({ length: 12 }, (_, idx) => + idx === 1 + ? createUploadErrorMock('order-file-1.txt', new Error('bad file'), 200) + : createUploadSuccessMock(`order-file-${idx}.txt`, Infinity) + ); + + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { files }, + }); + + await waitFor(() => { + expect(screen.getAllByTestId('queuedIcon').length).toBeGreaterThan(0); + }); + + await waitFor( + () => { + expect(screen.getByTestId('failedIcon')).toBeInTheDocument(); + }, + { timeout: 1000 } + ); + + const fileRowsText = screen.getAllByTestId('fileItem').map((row) => row.textContent || ''); + const existingIndex = fileRowsText.findIndex((text) => text.includes('existing-file.txt')); + const failedIndex = fileRowsText.findIndex((text) => text.includes('order-file-1.txt')); + const queuedIndex = fileRowsText.findIndex((text) => text.includes('order-file-11.txt')); + + expect(queuedIndex).toBeGreaterThan(-1); + expect(failedIndex).toBeGreaterThan(-1); + expect(existingIndex).toBeGreaterThan(-1); + expect(queuedIndex).toBeLessThan(failedIndex); + expect(failedIndex).toBeLessThan(existingIndex); + }); + + test('does not allow deleting a file while file upload is in progress', async () => { + const mocks = [createUploadSuccessMock('in-progress.txt', Infinity)]; + + render( + + + + ); + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: [new File(['content'], 'in-progress.txt', { type: 'text/plain' })], + }, + }); + + await waitFor(() => { + expect(screen.getByText('in-progress.txt')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('deleteFile')); + expect(screen.getByText('in-progress.txt')).toBeInTheDocument(); + }); + + test('allows deleting queued, failed and attached files', async () => { + const files = Array.from( + { length: 12 }, + (_, index) => new File(['content'], `state-file-${index}.txt`, { type: 'text/plain' }) + ); + + const mocks = [ + createUploadSuccessMock('state-file-0.txt', 100), + createUploadErrorMock('state-file-1.txt', new Error('bad file'), 150), + ...Array.from({ length: 8 }, (_, i) => createUploadSuccessMock(`state-file-${i + 2}.txt`, Infinity)), + createUploadSuccessMock('state-file-10.txt'), + createUploadSuccessMock('state-file-11.txt'), + ]; + + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { files }, + }); + + await waitFor(() => { + expect(screen.getAllByTestId('queuedIcon').length).toBeGreaterThan(0); + }); + + fireEvent.click( + screen + .getByText('state-file-10.txt') + .closest('[data-testid="fileItem"]')! + .querySelector('[data-testid="deleteFile"]')! + ); + await waitFor(() => { + expect(screen.queryByText('state-file-10.txt')).not.toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByTestId('failedIcon')).toBeInTheDocument(); + expect(screen.getByText('state-file-0.txt')).toBeInTheDocument(); + }); + + fireEvent.click( + screen + .getByText('state-file-1.txt') + .closest('[data-testid="fileItem"]')! + .querySelector('[data-testid="deleteFile"]')! + ); + expect(screen.queryByText('state-file-1.txt')).not.toBeInTheDocument(); + + fireEvent.click( + screen + .getByText('state-file-0.txt') + .closest('[data-testid="fileItem"]')! + .querySelector('[data-testid="deleteFile"]')! + ); + expect(screen.queryByText('state-file-0.txt')).not.toBeInTheDocument(); + }); + + test('shows warning notification on proceed when files have failed uploads', async () => { + const mocks = [createUploadErrorMock('bad-file.txt', new Error('Upload failed due to invalid content'))]; + + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: [new File(['content'], 'bad-file.txt', { type: 'text/plain' })], + }, + }); + + await waitFor(() => { + expect(screen.getAllByTestId('failedIcon').length).toBeGreaterThan(0); + expect(setNotificationSpy).toHaveBeenCalledWith( + 'Some file uploads failed, hover the failure to see the reason', + 'warning' + ); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Proceed' })); + + await waitFor(() => { + expect(setNotificationSpy).toHaveBeenCalledWith( + 'Remove or re-upload files that failed before saving.', + 'warning' + ); + }); + }); + + test('shows only close action for already saved files', async () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + + await waitFor(() => { + expect(screen.getByText('existing-file.txt')).toBeInTheDocument(); + expect(screen.getByTestId('deleteFile')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('attachedIcon')).not.toBeInTheDocument(); + expect(screen.queryByTestId('queuedIcon')).not.toBeInTheDocument(); + expect(screen.queryByTestId('failedIcon')).not.toBeInTheDocument(); + expect(screen.queryByTestId('retryFile')).not.toBeInTheDocument(); + }); + + test('allows retrying a failed file while another upload is still in progress', async () => { + const mocks = [ + createUploadSuccessMock('stuck-upload.txt', Infinity), + createUploadErrorMock('retry-failed.txt', new Error('temporary failure'), 50), + createUploadSuccessMock('retry-failed.txt', 50), + ]; + + render( + + + + ); + fireEvent.click(screen.getByTestId('addFiles')); + + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: [ + new File(['content'], 'stuck-upload.txt', { type: 'text/plain' }), + new File(['content'], 'retry-failed.txt', { type: 'text/plain' }), + ], + }, + }); + + await waitFor(() => { + expect(screen.getAllByTestId('failedIcon').length).toBeGreaterThan(0); + expect(screen.getByTestId('retryFile')).toBeEnabled(); + }); + + fireEvent.click(screen.getByTestId('retryFile')); + + await waitFor(() => { + expect(screen.queryByTestId('failedIcon')).not.toBeInTheDocument(); + expect(screen.queryByTestId('retryFile')).not.toBeInTheDocument(); + expect(screen.getByTestId('attachedIcon')).toBeInTheDocument(); + }); + }); + + test('retries upload request when rate limit errors occur', async () => { + vi.useFakeTimers(); + + let successResponseCount = 0; + + const mocks = [ + createUploadErrorMock('rate-limit-retry.txt', rateLimitError), + { + request: { query: UPLOAD_FILE_TO_KAAPI }, + variableMatcher: (variables: any) => variables?.media?.name === 'rate-limit-retry.txt', + result: () => { + successResponseCount += 1; + return { + data: { + uploadFilesearchFile: { + fileId: 'id-rate-limit-retry.txt', + filename: 'rate-limit-retry.txt', + uploadedAt: '2026-01-01', + fileSize: 12, + }, + }, + }; + }, + }, + ]; + + render( + + + + ); + fireEvent.click(screen.getByTestId('addFiles')); + + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: [new File(['content'], 'rate-limit-retry.txt', { type: 'text/plain' })], + }, + }); + + await act(async () => { + vi.runOnlyPendingTimers(); + await Promise.resolve(); + }); + expect(successResponseCount).toBe(0); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + vi.runOnlyPendingTimers(); + await Promise.resolve(); + }); + expect(successResponseCount).toBe(1); + expect(screen.getByTestId('attachedIcon')).toBeInTheDocument(); + }); + + test('shows failure state and tooltip message after exhausting rate limit retries', async () => { + vi.useFakeTimers(); + + const mocks = Array.from({ length: 5 }, () => createUploadErrorMock('rate-limit-exhausted.txt', rateLimitError)); + + render( + + + + ); + fireEvent.click(screen.getByTestId('addFiles')); + + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: [new File(['content'], 'rate-limit-exhausted.txt', { type: 'text/plain' })], + }, + }); + + await act(async () => { + vi.runOnlyPendingTimers(); + await Promise.resolve(); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000 + 4000 + 8000 + 16000); + vi.runOnlyPendingTimers(); + await Promise.resolve(); + vi.runOnlyPendingTimers(); + await Promise.resolve(); + }); + + expect(screen.getByTestId('failedIcon')).toBeInTheDocument(); + expect(setNotificationSpy).toHaveBeenCalledWith( + 'Some file uploads failed, hover the failure to see the reason', + 'warning' + ); + + vi.useRealTimers(); + fireEvent.mouseOver(screen.getByTestId('failedIcon')); + await waitFor(() => { + expect(screen.getByText('429 Too Many Requests')).toBeInTheDocument(); + }); + }); + + test('shows queued icon while upload waits in queue', async () => { + const files = Array.from( + { length: 11 }, + (_, index) => new File(['content'], `queued-state-${index}.txt`, { type: 'text/plain' }) + ); + + const mocks = [ + createUploadSuccessMock('queued-state-0.txt', Infinity), + ...files.slice(1).map((file) => createUploadSuccessMock(file.name)), + ]; + + render( + + + + ); + fireEvent.click(screen.getByTestId('addFiles')); + + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { files }, + }); + + await waitFor(() => { + expect(screen.getAllByTestId('queuedIcon').length).toBeGreaterThan(0); + }); + }); + + test('shows failed icon with error tooltip on upload failure', async () => { + const mocks = [createUploadErrorMock('broken-file.txt', new Error('Invalid file format'))]; + + render( + + + + ); + fireEvent.click(screen.getByTestId('addFiles')); + + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: [new File(['content'], 'broken-file.txt', { type: 'text/plain' })], + }, + }); + + await waitFor(() => { + expect(screen.getAllByTestId('failedIcon').length).toBeGreaterThan(0); + }); + + fireEvent.mouseOver(screen.getAllByTestId('failedIcon')[0]); + await waitFor(() => { + expect(screen.getByText(/invalid file format|failed to upload file/i)).toBeInTheDocument(); + }); + }); + + test('rejects entire selection when any file is above 20MB', async () => { + render( + + + + ); + fireEvent.click(screen.getByTestId('addFiles')); + + const validFile = new File(['content'], 'valid-file.txt', { type: 'text/plain' }); + const largeFile = new File(['content'], 'large-file.txt', { type: 'text/plain' }); + Object.defineProperty(largeFile, 'size', { value: 21 * 1024 * 1024 }); + + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: [validFile, largeFile], + }, + }); + + await waitFor(() => { + expect(setNotificationSpy).toHaveBeenCalledWith('large-file.txt is above 20MB', 'warning'); + }); + expect(screen.queryByText('valid-file.txt')).not.toBeInTheDocument(); + expect(screen.queryByText('large-file.txt')).not.toBeInTheDocument(); + }); + + test('keeps file uploading state when retry backoff sleep is aborted immediately', async () => { + const originalAbortController = globalThis.AbortController; + + class ImmediatelyAbortedAbortController { + public signal: AbortSignal; + constructor() { + const ac = new originalAbortController(); + ac.abort(); + this.signal = ac.signal; + } + abort() { + // no-op; signal is already aborted by construction + } + } + + (globalThis as any).AbortController = ImmediatelyAbortedAbortController; + + try { + const mocks = [createUploadErrorMock('immediate-abort-rate-limit.txt', rateLimitError)]; + + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: [new File(['content'], 'immediate-abort-rate-limit.txt', { type: 'text/plain' })], + }, + }); + + await waitFor(() => { + expect(screen.getAllByTestId('uploadingIcon').length).toBeGreaterThan(0); + }); + expect(screen.queryByTestId('failedIcon')).not.toBeInTheDocument(); + } finally { + (globalThis as any).AbortController = originalAbortController; + } + }); + + test('does not transition file to failed when upload is aborted during retry attempts', async () => { + const abortError = new Error('Aborted'); + (abortError as any).name = 'AbortError'; + + const mocks = [createUploadErrorMock('upload-abort-attempt.txt', abortError)]; + + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: [new File(['content'], 'upload-abort-attempt.txt', { type: 'text/plain' })], + }, + }); + + await waitFor(() => { + expect(screen.getAllByTestId('uploadingIcon').length).toBeGreaterThan(0); + }); + expect(screen.queryByTestId('failedIcon')).not.toBeInTheDocument(); + expect(screen.queryByTestId('attachedIcon')).not.toBeInTheDocument(); + }); + + test('shows upload error tooltip when an abort-like error is not recognized as AbortError', async () => { + const originalAbortController = globalThis.AbortController; + const originalDOMException = globalThis.DOMException; + + class ImmediatelyAbortedAbortController { + public signal: AbortSignal; + constructor() { + const ac = new originalAbortController(); + ac.abort(); + this.signal = ac.signal; + } + abort() { + // no-op + } + } + + class FakeDOMException extends Error { + name = 'NotAbortError'; + constructor(message: string, _unused: string) { + super(message); + } + } + + (globalThis as any).AbortController = ImmediatelyAbortedAbortController; + (globalThis as any).DOMException = FakeDOMException; + + try { + const rateLimitError = new Error('429 Too Many Requests'); + (rateLimitError as any).networkError = { statusCode: 429 }; + + const mocks = [createUploadErrorMock('abort-not-recognized.txt', rateLimitError)]; + + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: [new File(['content'], 'abort-not-recognized.txt', { type: 'text/plain' })], + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('failedIcon')).toBeInTheDocument(); + }); + + fireEvent.mouseOver(screen.getByTestId('failedIcon')); + await waitFor(() => { + expect(screen.getByText('Aborted')).toBeInTheDocument(); + }); + } finally { + (globalThis as any).AbortController = originalAbortController; + (globalThis as any).DOMException = originalDOMException; + } + }); + + test('marks file as failed and shows tooltip message after upload error', async () => { + const mocks = [createUploadErrorMock('then-failed.txt', new Error('then-failed-error'))]; + + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: [new File(['content'], 'then-failed.txt', { type: 'text/plain' })], + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('failedIcon')).toBeInTheDocument(); + }); + + fireEvent.mouseOver(screen.getByTestId('failedIcon')); + await waitFor(() => { + expect(screen.getByText('then-failed-error')).toBeInTheDocument(); + }); + }); + + test('marks file as failed when uploadFile rejects and sets tooltip from upload error', async () => { + const originalMapDelete = Map.prototype.delete; + + vi.spyOn(Map.prototype, 'delete').mockImplementation(function (this: Map, key: any) { + if (typeof key === 'string' && key.includes('catch-reject-delete.txt')) { + throw new Error('map-delete-explosion'); + } + return originalMapDelete.call(this, key); + }); + + const mocks = [createUploadSuccessMock('catch-reject-delete.txt')]; + + try { + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: [new File(['content'], 'catch-reject-delete.txt', { type: 'text/plain' })], + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('failedIcon')).toBeInTheDocument(); + }); + + fireEvent.mouseOver(screen.getByTestId('failedIcon')); + await waitFor(() => { + expect(screen.getByText('map-delete-explosion')).toBeInTheDocument(); + }); + } finally { + vi.restoreAllMocks(); + } + }); + + test('does not mark file as failed when uploadFile rejects with AbortError', async () => { + const originalMapDelete = Map.prototype.delete; + + const abortError = new Error('Aborted'); + (abortError as any).name = 'AbortError'; + + vi.spyOn(Map.prototype, 'delete').mockImplementation(function (this: Map, key: any) { + if (typeof key === 'string' && key.includes('catch-abort-early-return.txt')) { + throw abortError; + } + return originalMapDelete.call(this, key); + }); + + const mocks = [createUploadSuccessMock('catch-abort-early-return.txt')]; + + try { + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { files: [new File(['content'], 'catch-abort-early-return.txt', { type: 'text/plain' })] }, + }); + + await waitFor(() => { + expect(screen.getAllByTestId('uploadingIcon').length).toBeGreaterThan(0); + }); + expect(screen.queryByTestId('failedIcon')).not.toBeInTheDocument(); + } finally { + vi.restoreAllMocks(); + } + }); + + test('falls back to default failure message when upload error has no message', async () => { + const originalMapDelete = Map.prototype.delete; + + vi.spyOn(Map.prototype, 'delete').mockImplementation(function (this: Map, key: any) { + if (typeof key === 'string' && key.includes('catch-fallback-default-error.txt')) { + throw { name: 'SomeError' }; + } + return originalMapDelete.call(this, key); + }); + + const mocks = [createUploadSuccessMock('catch-fallback-default-error.txt')]; + + try { + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { files: [new File(['content'], 'catch-fallback-default-error.txt', { type: 'text/plain' })] }, + }); + + await waitFor(() => { + expect(screen.getByTestId('failedIcon')).toBeInTheDocument(); + }); + + fireEvent.mouseOver(screen.getByTestId('failedIcon')); + await waitFor(() => { + expect(screen.getByText('Failed to upload file')).toBeInTheDocument(); + }); + } finally { + vi.restoreAllMocks(); + } + }); + + test('does not log enqueue errors when Promise.all rejects with AbortError', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const abortError = new Error('Aborted'); + (abortError as any).name = 'AbortError'; + + const originalPromiseAll = Promise.all; + (Promise as any).all = () => Promise.reject(abortError); + + try { + const mocks = [createUploadSuccessMock('abort-catch-console.txt')]; + + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: [new File(['content'], 'abort-catch-console.txt', { type: 'text/plain' })], + }, + }); + + await waitFor(() => { + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + } finally { + vi.restoreAllMocks(); + Promise.all = originalPromiseAll; + } + }); + + test('logs enqueue errors when Promise.all rejects with a non-abort error', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const error = new Error('queue-push-broke'); + + const originalPromiseAll = Promise.all; + (Promise as any).all = () => Promise.reject(error); + + try { + const mocks = [createUploadSuccessMock('console-catch-nonabort.txt')]; + + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { + files: [new File(['content'], 'console-catch-nonabort.txt', { type: 'text/plain' })], + }, + }); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(consoleErrorSpy.mock.calls[0][0]).toContain('Error uploading files:'); + }); + } finally { + vi.restoreAllMocks(); + Promise.all = originalPromiseAll; + } + }); + + test('aborts the upload controller when a file is removed and its controller entry still exists', async () => { + const fileName = 'remove-controller-entry.txt'; + const abortSpy = vi.spyOn(AbortController.prototype, 'abort'); + + const originalMapDelete = Map.prototype.delete; + vi.spyOn(Map.prototype, 'delete').mockImplementation(function (this: Map, key: any) { + if (typeof key === 'string' && key.includes(fileName)) { + return true; + } + return originalMapDelete.call(this, key); + }); + + const mocks = [createUploadErrorMock(fileName, new Error('upload-failure'))]; + + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + fireEvent.change(screen.getByTestId('uploadFile'), { + target: { files: [new File(['content'], fileName, { type: 'text/plain' })] }, + }); + + await waitFor(() => { + expect(screen.getByText(fileName)).toBeInTheDocument(); + expect(screen.getByTestId('failedIcon')).toBeInTheDocument(); + }); + + const fileItem = screen.getByText(fileName).closest('[data-testid="fileItem"]')!; + const deleteButton = fileItem.querySelector('[data-testid="deleteFile"]') as HTMLButtonElement; + expect(deleteButton).not.toBeDisabled(); + + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(screen.queryByText(fileName)).not.toBeInTheDocument(); + }); + + expect(abortSpy).toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + test('closes dialog without API call when no files have changed', async () => { + const existingFile = { fileId: 'existing-id', filename: 'existing.txt', uploadedAt: '2026-01-01', fileSize: 10 }; + + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + + await waitFor(() => { + expect(screen.getByText('existing.txt')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Proceed' })); + + await waitFor(() => { + expect(screen.queryByText('Manage Knowledge Base')).not.toBeInTheDocument(); + }); + }); + + test('calls setErrorMessage when createKnowledgeBase returns an error', async () => { + const errorSpy = vi.spyOn(Notification, 'setErrorMessage').mockImplementation(() => {}); + + const keepFile = { fileId: 'keep-id', filename: 'keep.txt', uploadedAt: '2026-01-01', fileSize: 10 }; + const removeFile = { fileId: 'remove-id', filename: 'remove.txt', uploadedAt: '2026-01-02', fileSize: 15 }; + + const kbErrorMock = { + request: { + query: CREATE_KNOWLEDGE_BASE, + variables: { + createKnowledgeBaseId: 'kb-1', + mediaInfo: [{ fileId: 'keep-id', filename: 'keep.txt', uploadedAt: '2026-01-01', fileSize: 10 }], + }, + }, + error: new Error('KB creation failed'), + }; + + render( + + + + ); + + fireEvent.click(screen.getByTestId('addFiles')); + + await waitFor(() => { + expect(screen.getByText('remove.txt')).toBeInTheDocument(); + }); + + fireEvent.click( + screen + .getByText('remove.txt') + .closest('[data-testid="fileItem"]')! + .querySelector('[data-testid="deleteFile"]')! + ); + + await waitFor(() => { + expect(screen.queryByText('remove.txt')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Proceed' })).not.toBeDisabled(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Proceed' })); + + await waitFor(() => { + expect(errorSpy).toHaveBeenCalled(); + }); + + errorSpy.mockRestore(); + }); +}); + +describe('KnowledgeBaseOptions temperature input validation', () => { + const renderOptions = () => + render( + + + + ); + + test('shows error text when temperature value is out of range (> 2)', () => { + renderOptions(); + const slider = screen.getByTestId('sliderDisplay'); + fireEvent.change(slider, { target: { value: '3' } }); + expect(screen.getByText('Temperature value should be between 0-2')).toBeInTheDocument(); + }); + + test('shows error text when temperature value is negative', () => { + renderOptions(); + const slider = screen.getByTestId('sliderDisplay'); + fireEvent.change(slider, { target: { value: '-1' } }); + expect(screen.getByText('Temperature value should be between 0-2')).toBeInTheDocument(); + }); + + test('clears error and calls setFieldValue when a valid value is entered after an error', () => { + const setFieldValue = vi.fn(); + render( + + + + ); + const slider = screen.getByTestId('sliderDisplay'); + + fireEvent.change(slider, { target: { value: '3' } }); + expect(screen.getByText('Temperature value should be between 0-2')).toBeInTheDocument(); + + fireEvent.change(slider, { target: { value: '1.5' } }); + expect(screen.queryByText('Temperature value should be between 0-2')).not.toBeInTheDocument(); + expect(setFieldValue).toHaveBeenCalledWith('temperature', 1.5); + }); +}); diff --git a/src/containers/Assistants/AssistantOptions/KnowledgeBaseOptions.tsx b/src/containers/Assistants/AssistantOptions/KnowledgeBaseOptions.tsx new file mode 100644 index 000000000..715bb520c --- /dev/null +++ b/src/containers/Assistants/AssistantOptions/KnowledgeBaseOptions.tsx @@ -0,0 +1,739 @@ +import { useMutation } from '@apollo/client'; +import AccessTimeIcon from '@mui/icons-material/AccessTime'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import ReplayIcon from '@mui/icons-material/Replay'; +import { Button, CircularProgress, IconButton, Slider, Tooltip, Typography } from '@mui/material'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import AddIcon from 'assets/images/AddGreenIcon.svg?react'; +import DatabaseIcon from 'assets/images/database.svg?react'; +import FileIcon from 'assets/images/FileGreen.svg?react'; + +import { setErrorMessage, setNotification } from 'common/notification'; +import { DialogBox } from 'components/UI/DialogBox/DialogBox'; +import HelpIcon from 'components/UI/HelpIcon/HelpIcon'; + +import { CREATE_KNOWLEDGE_BASE, UPLOAD_FILE_TO_KAAPI } from 'graphql/mutations/Assistant'; + +import styles from './KnowledgeBaseOptions.module.css'; +interface KnowledgeBaseOptionsProps { + formikValues: any; + setFieldValue: (field: string, value: any) => void; + formikErrors: any; + formikTouched: any; + knowledgeBaseId: string | null; + isLegacyVectorStore: boolean; + onFilesChange: (hasChanges: boolean) => void; + vectorStoreId: string; + validateForm: () => void; + disabled: boolean; +} + +type FileStatus = 'attached' | 'queued' | 'uploading' | 'failed'; + +interface AssistantFile { + fileId?: string; + filename: string; + uploadedAt?: string; + fileSize?: number; + status: FileStatus; + tempId: string; + errorMessage?: string; + sourceFile?: File; +} + +interface UploadedFile { + fileId?: string; + filename: string; + uploadedAt?: string; + fileSize?: number; +} + +const MAX_CONCURRENT_UPLOADS = 10; +const MAX_RETRY_ATTEMPTS = 5; +const INITIAL_BACKOFF_MS = 2000; +const MAX_FILE_SIZE_MB = 20; +const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; + +const temperatureInfo = + 'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.'; +const filesInfo = + 'Enables the assistant with knowledge from files that you or your users upload. Once a file is uploaded, the assistant automatically decides when to retrieve content based on user requests.'; + +export const KnowledgeBaseOptions = ({ + formikValues, + setFieldValue, + formikErrors, + formikTouched, + knowledgeBaseId, + isLegacyVectorStore, + onFilesChange, + vectorStoreId, + validateForm, + disabled, +}: KnowledgeBaseOptionsProps) => { + const mapInitialFileToAssistantFile = (file: any): AssistantFile => ({ + ...file, + status: 'attached', + tempId: file.fileId || file.filename, + }); + + const [showUploadDialog, setShowUploadDialog] = useState(false); + const [files, setFiles] = useState(formikValues.initialFiles.map(mapInitialFileToAssistantFile)); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const uploadSessionRef = useRef(0); + const activeControllersRef = useRef([]); + const uploadControllersRef = useRef>(new Map()); + const pendingUploadQueueRef = useRef< + Array<{ file: File; tempId: string; sessionId: number; resolveResult: (success: boolean) => void }> + >([]); + const activeUploadsRef = useRef(0); + const isUploadDialogOpenRef = useRef(false); + const { t } = useTranslation(); + let fileUploadDisabled = false; + if (isLegacyVectorStore || disabled) { + fileUploadDisabled = true; + } + + useEffect(() => { + setFiles(formikValues.initialFiles.map(mapInitialFileToAssistantFile)); + }, [formikValues.initialFiles]); + + useEffect(() => { + isUploadDialogOpenRef.current = showUploadDialog; + }, [showUploadDialog]); + + const [uploadFileToKaapi] = useMutation(UPLOAD_FILE_TO_KAAPI); + const [createKnowledgeBase, { loading: addingFiles }] = useMutation(CREATE_KNOWLEDGE_BASE); + + const isAbortError = (error: any) => { + const message = error?.message || error?.networkError?.message; + return Boolean( + error?.name === 'AbortError' || + error?.networkError?.name === 'AbortError' || + message?.includes('aborted') || + message?.includes('AbortError') + ); + }; + + const isRateLimitError = (uploadError: any) => { + const networkStatus = uploadError?.networkError?.statusCode || uploadError?.networkError?.status; + const graphQLErrorCode = uploadError?.graphQLErrors?.[0]?.extensions?.code; + const message = uploadError?.message || uploadError?.networkError?.message || ''; + + return ( + networkStatus === 429 || + graphQLErrorCode === 'TOO_MANY_REQUESTS' || + message.includes('429') || + message.toLowerCase().includes('too many requests') + ); + }; + + const cancelActiveRequests = () => { + activeControllersRef.current.forEach((controller) => controller.abort()); + activeControllersRef.current = []; + uploadControllersRef.current.clear(); + }; + + const sleepWithAbort = (durationMs: number, signal: AbortSignal) => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + + const timeout = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, durationMs); + + const onAbort = () => { + clearTimeout(timeout); + reject(new DOMException('Aborted', 'AbortError')); + }; + + signal.addEventListener('abort', onAbort, { once: true }); + }); + + const updateLoadingFromStatuses = (sessionId: number) => { + if (!isCurrentSessionRef(sessionId)) return; + const hasQueuedForSession = pendingUploadQueueRef.current.some((queueItem) => queueItem.sessionId === sessionId); + const hasActiveForSession = activeUploadsRef.current > 0; + setLoading(hasQueuedForSession || hasActiveForSession); + }; + + const closeUploadDialog = () => { + uploadSessionRef.current += 1; + isUploadDialogOpenRef.current = false; + pendingUploadQueueRef.current = []; + cancelActiveRequests(); + setLoading(false); + setFiles(formikValues.initialFiles.map(mapInitialFileToAssistantFile)); + setShowUploadDialog(false); + }; + + const uploadFile = async ( + file: File, + tempId: string + ): Promise<{ uploadedFile: UploadedFile | null; errorMessage: string | null; aborted: boolean }> => { + let uploadedFile: UploadedFile | null = null; + let errorMessage: string | null = null; + let aborted = false; + const controller = new AbortController(); + activeControllersRef.current.push(controller); + uploadControllersRef.current.set(tempId, controller); + + const runAttempt = async () => { + let attemptError: any = null; + let uploadedData: any = null; + const { data } = await uploadFileToKaapi({ + variables: { + media: file, + }, + context: { + fetchOptions: { + signal: controller.signal, + }, + }, + onCompleted: (payload) => { + uploadedData = payload?.uploadFilesearchFile; + }, + onError: (uploadError) => { + attemptError = uploadError; + }, + }); + + if (attemptError) { + throw attemptError; + } + + const responseData = uploadedData || data?.uploadFilesearchFile; + uploadedFile = { + fileId: responseData?.fileId, + filename: responseData?.filename, + uploadedAt: responseData?.uploadedAt, + fileSize: responseData?.fileSize, + }; + }; + + try { + for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt += 1) { + try { + await runAttempt(); + break; + } catch (uploadError: any) { + if (isAbortError(uploadError)) { + aborted = true; + break; + } + + errorMessage = uploadError?.message || 'Failed to upload file'; + if (!isRateLimitError(uploadError) || attempt >= MAX_RETRY_ATTEMPTS) { + break; + } + + const backoffMs = INITIAL_BACKOFF_MS * 2 ** (attempt - 1); + await sleepWithAbort(backoffMs, controller.signal); + } + } + } catch (uploadError: any) { + if (isAbortError(uploadError)) { + aborted = true; + } else { + errorMessage = uploadError?.message || 'Failed to upload file'; + } + } finally { + activeControllersRef.current = activeControllersRef.current.filter((entry) => entry !== controller); + uploadControllersRef.current.delete(tempId); + } + + return { uploadedFile, errorMessage, aborted }; + }; + + const getSortedFiles = (list: AssistantFile[]) => { + const statusOrder: Record = { + uploading: 0, + queued: 1, + failed: 2, + attached: 3, + }; + + return [...list].sort((first, second) => statusOrder[first.status] - statusOrder[second.status]); + }; + + const processUploadQueue = () => { + while (activeUploadsRef.current < MAX_CONCURRENT_UPLOADS && pendingUploadQueueRef.current.length > 0) { + const nextUpload = pendingUploadQueueRef.current.shift(); + if (!nextUpload) return; + + const { file, tempId, sessionId, resolveResult } = nextUpload; + if (!isCurrentSessionRef(sessionId)) { + resolveResult(false); + continue; + } + + activeUploadsRef.current += 1; + setFiles((prevFiles) => + prevFiles.map((fileItem) => + fileItem.tempId === tempId ? { ...fileItem, status: 'uploading', errorMessage: '' } : fileItem + ) + ); + + uploadFile(file, tempId) + .then(({ uploadedFile, errorMessage, aborted }) => { + if (!isCurrentSessionRef(sessionId) || aborted) { + resolveResult(false); + return; + } + + if (uploadedFile) { + const attachedFile: AssistantFile = { + fileId: uploadedFile.fileId, + filename: uploadedFile.filename, + uploadedAt: uploadedFile.uploadedAt, + fileSize: uploadedFile.fileSize, + status: 'attached', + tempId, + sourceFile: file, + }; + + setFiles((prevFiles) => + prevFiles.map((fileItem) => (fileItem.tempId === tempId ? attachedFile : fileItem)) + ); + resolveResult(true); + return; + } + + setFiles((prevFiles) => + prevFiles.map((fileItem) => + fileItem.tempId === tempId + ? { ...fileItem, status: 'failed', errorMessage: errorMessage || 'Failed to upload file' } + : fileItem + ) + ); + resolveResult(false); + }) + .catch((uploadError) => { + if (!isCurrentSessionRef(sessionId) || isAbortError(uploadError)) { + resolveResult(false); + return; + } + + setFiles((prevFiles) => + prevFiles.map((fileItem) => + fileItem.tempId === tempId + ? { ...fileItem, status: 'failed', errorMessage: uploadError?.message || 'Failed to upload file' } + : fileItem + ) + ); + resolveResult(false); + }) + .finally(() => { + activeUploadsRef.current -= 1; + updateLoadingFromStatuses(sessionId); + processUploadQueue(); + }); + } + }; + + const enqueueFilesForUpload = (uploadFiles: Array<{ sourceFile: File; tempId: string }>, sessionId: number) => { + const uploadTasks = uploadFiles.map(({ sourceFile, tempId }) => { + return new Promise((resolveResult) => { + pendingUploadQueueRef.current.push({ file: sourceFile, tempId, sessionId, resolveResult }); + }); + }); + + processUploadQueue(); + return Promise.all(uploadTasks); + }; + + const handleFileChange = (event: any) => { + const inputFiles = Array.from(event.target.files || []) as File[]; + if (inputFiles.length === 0) return; + + const oversizedFiles = inputFiles.filter((file) => file.size > MAX_FILE_SIZE_BYTES); + if (oversizedFiles.length > 0) { + const oversizedFileNames = oversizedFiles.map((file) => file.name).join(', '); + const fileVerb = oversizedFiles.length > 1 ? 'are' : 'is'; + setNotification(`${oversizedFileNames} ${fileVerb} above 20MB`, 'warning'); + return; + } + + setLoading(true); + const sessionId = uploadSessionRef.current; + const queuedFiles = inputFiles.map((file, index) => ({ + filename: file.name, + status: 'queued' as const, + tempId: `${file.name}-${Date.now()}-${index}`, + errorMessage: '', + sourceFile: file, + })); + + setFiles((prevFiles) => [...prevFiles, ...queuedFiles]); + + enqueueFilesForUpload( + queuedFiles.map((file) => ({ sourceFile: file.sourceFile as File, tempId: file.tempId })), + sessionId + ) + .then((results) => { + if (!isCurrentSessionRef(sessionId)) return; + const hasFailures = results.some((result) => !result); + if (hasFailures) { + setNotification('Some file uploads failed, hover the failure to see the reason', 'warning'); + } + }) + .catch((error) => { + if (isAbortError(error)) return; + console.error('Error uploading files:', error); + }) + .finally(() => { + if (!isCurrentSessionRef(sessionId)) return; + updateLoadingFromStatuses(sessionId); + }); + }; + + const isCurrentSessionRef = (sessionId: number) => uploadSessionRef.current === sessionId; + + const handleRemoveFile = (file: AssistantFile) => { + const { tempId } = file; + + const removedQueueEntries = pendingUploadQueueRef.current.filter((queueItem) => queueItem.tempId === tempId); + pendingUploadQueueRef.current = pendingUploadQueueRef.current.filter((queueItem) => queueItem.tempId !== tempId); + removedQueueEntries.forEach(({ resolveResult }) => resolveResult(false)); + + const uploadController = uploadControllersRef.current.get(tempId); + if (uploadController) { + uploadController.abort(); + uploadControllersRef.current.delete(tempId); + } + + setFiles((prevFiles) => prevFiles.filter((fileItem) => fileItem.tempId !== tempId)); + updateLoadingFromStatuses(uploadSessionRef.current); + }; + + const handleRetryFile = (file: AssistantFile) => { + if (!file.sourceFile) return; + const sessionId = uploadSessionRef.current; + setLoading(true); + setFiles((prevFiles) => + prevFiles.map((fileItem) => + fileItem.tempId === file.tempId ? { ...fileItem, status: 'queued', errorMessage: '' } : fileItem + ) + ); + enqueueFilesForUpload([{ sourceFile: file.sourceFile, tempId: file.tempId }], sessionId).finally(() => { + updateLoadingFromStatuses(sessionId); + }); + }; + + const hasFilesChanged = () => { + const attachedFiles = files.filter((file) => file.status === 'attached'); + if (attachedFiles.length !== formikValues.initialFiles.length) { + return true; + } + + const initialFileIds = new Set(formikValues.initialFiles.map((file: any) => file.fileId)); + return attachedFiles.some((file) => !file.fileId || !initialFileIds.has(file.fileId)); + }; + + const handleFileUpload = () => { + if (files.some((file) => file.status === 'failed')) { + setNotification('Remove or re-upload files that failed before saving.', 'warning'); + return; + } + + if (!hasFilesChanged()) { + setShowUploadDialog(false); + return; + } + + const attachedFiles = files + .filter((file) => file.status === 'attached') + .map(({ status, tempId, sourceFile, ...rest }) => rest) + .filter( + (file, index, allFiles) => + allFiles.findIndex( + (entry) => + entry.fileId === file.fileId && entry.filename === file.filename && entry.uploadedAt === file.uploadedAt + ) === index + ); + + const controller = new AbortController(); + activeControllersRef.current.push(controller); + + createKnowledgeBase({ + variables: { + createKnowledgeBaseId: knowledgeBaseId || null, + mediaInfo: attachedFiles, + }, + context: { + fetchOptions: { + signal: controller.signal, + }, + }, + onCompleted: ({ createKnowledgeBase: knowledgeBaseData }) => { + const updatedFiles = files + .filter((file) => file.status === 'attached') + .map(({ status, tempId, sourceFile, ...rest }) => rest); + setFiles(updatedFiles.map(mapInitialFileToAssistantFile)); + setFieldValue('initialFiles', updatedFiles); + setFieldValue('knowledgeBaseVersionId', knowledgeBaseData.knowledgeBase.knowledgeBaseVersionId); + setTimeout(() => validateForm(), 0); + setFieldValue('knowledgeBaseName', knowledgeBaseData.knowledgeBase.name); + onFilesChange(true); + setNotification("Knowledge base creation in progress, will notify once it's done", 'success'); + setShowUploadDialog(false); + }, + onError: (error) => { + setErrorMessage(error); + }, + }).finally(() => { + activeControllersRef.current = activeControllersRef.current.filter((entry) => entry !== controller); + }); + }; + + let dialog; + if (showUploadDialog) { + dialog = ( + +
+ {isLegacyVectorStore ? ( +

+ This assistant was created before 10/03/2026. Knowledge base files for old assistants are read-only. You + can edit Knowledge base by cloning this assistant. It will copy the prompt and other settings, and + re-upload the files in the new assistant. +

+ ) : ( + <> +

{t('You are adding more files to existing Knowledge Base')}

+
+
+
+ {t('Selected File(s)')} + +
+ +
+ {getSortedFiles(files).map((file, index) => ( +
+
+ {file.status === 'uploading' ? ( + + ) : ( + + )} +
+
{file.filename}
+
+
+ {file.sourceFile && ( + <> + {file.status === 'queued' && ( + + )} + {file.status === 'failed' && ( + <> + + + + + + handleRetryFile(file)} + > + + + + + + )} + {file.status === 'attached' && ( + + )} + + )} +
+
+ handleRemoveFile(file)} + > + + +
+
+
+ ))} +
+
+ +
+

+ Information in the attached files will be available to this assistant.{' '} + + Allowed file formats + +

+

+ Individual File Limit: {MAX_FILE_SIZE_MB}MB +

+
+
+ + )} +
+
+ ); + } + return ( +
+
+
+ + Knowledge Base Files * + + + + + + +
+ {formikValues.knowledgeBaseVersionId && ( +
+
+ +
+ {vectorStoreId || formikValues.knowledgeBaseName} +
+
+ {files.length > 0 && ( + + {files.length} {files.length === 1 ? 'file' : 'files'} + + )} +
+ )} + {formikTouched?.knowledgeBaseVersionId && formikErrors?.knowledgeBaseVersionId && ( +

{formikErrors.knowledgeBaseVersionId}

+ )} + {isLegacyVectorStore && ( +

+ This assistant was created before 10/03/2026. Knowledge base files for old assistants are read-only. You can + edit Knowledge base by cloning this assistant. It will copy the prompt and other settings, and re-upload the + files in the new assistant. +

+ )} +
+ +
+ + {t('Temperature')}* + + +
+
+ { + setFieldValue('temperature', value); + }} + value={formikValues.temperature} + step={0.01} + max={2} + min={0} + disabled={disabled} + /> + { + const value = parseFloat(event.target.value); + if (value < 0 || value > 2) { + setError(true); + return; + } + setError(false); + setFieldValue('temperature', value); + }} + className={`${styles.SliderDisplay} ${error ? styles.Error : ''}`} + disabled={disabled && !isLegacyVectorStore} + /> +
+ {error &&

Temperature value should be between 0-2

} +
+
+ + {dialog} +
+ ); +}; diff --git a/src/containers/Assistants/Assistants.test.tsx b/src/containers/Assistants/Assistants.test.tsx index c2f8bb91f..9979a88ff 100644 --- a/src/containers/Assistants/Assistants.test.tsx +++ b/src/containers/Assistants/Assistants.test.tsx @@ -65,7 +65,7 @@ test('it renders the list properly and switches between items', async () => { expect(screen.getByText('Loading...')).toBeInTheDocument(); await waitFor(() => { - expect(screen.getByText('VectorStore-77ae3597')).toBeInTheDocument(); + expect(screen.getByText('vs_abc123')).toBeInTheDocument(); }); }); @@ -88,7 +88,7 @@ test('it creates an assistant', async () => { fireEvent.change(inputs[1], { target: { value: 'test name' } }); fireEvent.change(inputs[2], { target: { value: 'test instructions' } }); - fireEvent.change(screen.getByRole('sliderDisplay'), { target: { value: 1.5 } }); + fireEvent.change(screen.getByTestId('sliderDisplay'), { target: { value: 1.5 } }); fireEvent.click(autocompletes[0], { key: 'Enter' }); autocompletes[0].focus(); @@ -97,7 +97,7 @@ test('it creates an assistant', async () => { fireEvent.click(screen.getByTestId('addFiles')); await waitFor(() => { - expect(screen.getByTestId('dialogTitle')).toHaveTextContent('Manage Knowledge Base'); + expect(screen.getByTestId('dialogTitle')).toHaveTextContent('Manage Files'); }); fireEvent.click(screen.getByTestId('ok-button')); @@ -149,7 +149,7 @@ test('it creates an assistant without a knowledge base', async () => { fireEvent.change(inputs[1], { target: { value: 'test name' } }); fireEvent.change(inputs[2], { target: { value: 'test instructions' } }); - fireEvent.change(screen.getByRole('sliderDisplay'), { target: { value: 1.5 } }); + fireEvent.change(screen.getByTestId('sliderDisplay'), { target: { value: 1.5 } }); fireEvent.click(autocompletes[0], { key: 'Enter' }); autocompletes[0].focus(); @@ -220,7 +220,7 @@ test('it uploads files to assistant', async () => { fireEvent.click(screen.getByTestId('addFiles')); await waitFor(() => { - expect(screen.getByTestId('dialogTitle')).toHaveTextContent('Manage Knowledge Base'); + expect(screen.getByTestId('dialogTitle')).toHaveTextContent('Manage Files'); }); fireEvent.click(screen.getByTestId('ok-button')); @@ -320,7 +320,7 @@ test('it updates the assistant', async () => { fireEvent.change(inputs[1], { target: { value: 'test name' } }); fireEvent.change(inputs[2], { target: { value: 'test instructions' } }); - fireEvent.change(screen.getByRole('sliderDisplay'), { target: { value: 1.5 } }); + fireEvent.change(screen.getByTestId('sliderDisplay'), { target: { value: 1.5 } }); fireEvent.click(screen.getByTestId('submitAction')); @@ -373,13 +373,13 @@ test('it should show errors for invalid value in temperature', async () => { expect(screen.getByText('Instructions (Prompt)*')).toBeInTheDocument(); }); - fireEvent.change(screen.getByRole('sliderDisplay'), { target: { value: 2.5 } }); + fireEvent.change(screen.getByTestId('sliderDisplay'), { target: { value: 2.5 } }); await waitFor(() => { expect(screen.getByText('Temperature value should be between 0-2')).toBeInTheDocument(); }); - fireEvent.change(screen.getByRole('sliderDisplay'), { target: { value: -2.5 } }); + fireEvent.change(screen.getByTestId('sliderDisplay'), { target: { value: -2.5 } }); await waitFor(() => { expect(screen.getByText('Temperature value should be between 0-2')).toBeInTheDocument(); @@ -471,7 +471,7 @@ test('uploading multiple files and error messages', async () => { fireEvent.click(screen.getByTestId('addFiles')); await waitFor(() => { - expect(screen.getByTestId('dialogTitle')).toHaveTextContent('Manage Knowledge Base'); + expect(screen.getByTestId('dialogTitle')).toHaveTextContent('Manage Files'); }); expect(screen.getAllByTestId('fileItem')).toHaveLength(1); diff --git a/src/i18n/en/en.json b/src/i18n/en/en.json index 7e3fd7092..5789c2c81 100644 --- a/src/i18n/en/en.json +++ b/src/i18n/en/en.json @@ -593,6 +593,11 @@ "Assistant not found": "Assistant not found", "Unsaved changes": "Unsaved changes", "Knowledge Base": "Knowledge Base", + "Manage Knowledge Base": "Manage Knowledge Base", + "You are adding more files to existing Knowledge Base": "You are adding more files to existing Knowledge Base", + "Selected File(s)": "Selected File(s)", + "Add Files": "Add Files", + "Knowledge Base upload in progress...": "Knowledge Base upload in progress...", "Notes (Optional)": "Notes (Optional)", "Add notes on changes made to this assistant": "Add notes on changes made to this assistant", "A new version is being created": "A new version is being created", diff --git a/src/mocks/Assistants.ts b/src/mocks/Assistants.ts index e306c32b7..2f178fe73 100644 --- a/src/mocks/Assistants.ts +++ b/src/mocks/Assistants.ts @@ -250,7 +250,7 @@ const updateAssistant = { }, }; -const removeAssistant = { +export const removeAssistant = { request: { query: DELETE_ASSISTANT, variables: { @@ -591,7 +591,7 @@ export const updateAssistantErrorMock = { export const filterAssistantsMock = { request: { query: FILTER_ASSISTANTS, - variables: { filter: {}, opts: { limit: 50, offset: 0, order: 'ASC', orderWith: 'name' } }, + variables: { filter: {}, opts: { limit: 50, offset: 0, order: 'DESC', orderWith: 'updated_at' } }, }, result: { data: { @@ -705,7 +705,7 @@ const cloneAssistantErrorMock = (id: string, versionId?: string) => ({ export const filterAssistantsAfterCloneMock = { request: { query: FILTER_ASSISTANTS, - variables: { filter: {}, opts: { limit: 50, offset: 0, order: 'ASC', orderWith: 'name' } }, + variables: { filter: {}, opts: { limit: 50, offset: 0, order: 'DESC', orderWith: 'updated_at' } }, }, result: { data: { diff --git a/src/mocks/KnowledgeBase.ts b/src/mocks/KnowledgeBase.ts new file mode 100644 index 000000000..b6c18b94a --- /dev/null +++ b/src/mocks/KnowledgeBase.ts @@ -0,0 +1,48 @@ +import { UPLOAD_FILE_TO_KAAPI } from 'graphql/mutations/Assistant'; + +export const knowledgeBaseOptionsBaseProps = { + formikValues: { + initialFiles: [], + knowledgeBaseVersionId: '', + knowledgeBaseName: '', + temperature: 1, + }, + setFieldValue: () => {}, + formikErrors: {}, + formikTouched: {}, + knowledgeBaseId: 'kb-1', + isLegacyVectorStore: false, + onFilesChange: () => {}, + vectorStoreId: 'vs-1', + validateForm: () => {}, + disabled: false, +}; + +export const createUploadSuccessMock = (filename: string, delay?: number) => ({ + request: { query: UPLOAD_FILE_TO_KAAPI }, + variableMatcher: (variables: any) => variables?.media?.name === filename, + ...(delay !== undefined ? { delay } : {}), + result: { + data: { + uploadFilesearchFile: { + fileId: `id-${filename}`, + filename, + uploadedAt: '2026-01-01', + fileSize: 12, + }, + }, + }, +}); + +export const createUploadErrorMock = (filename: string, error: Error, delay?: number) => ({ + request: { query: UPLOAD_FILE_TO_KAAPI }, + variableMatcher: (variables: any) => variables?.media?.name === filename, + ...(delay !== undefined ? { delay } : {}), + error, +}); + +export const rateLimitError = (() => { + const err = new Error('429 Too Many Requests'); + (err as any).networkError = { statusCode: 429 }; + return err; +})();