diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c135c525..97230bfa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import Agents from './agents'; import SharedAgentGate from './agents/SharedAgentGate'; import ActionButtons from './components/ActionButtons'; import Spinner from './components/Spinner'; +import UploadToast from './components/UploadToast'; import Conversation from './conversation/Conversation'; import { SharedConversation } from './conversation/SharedConversation'; import { useDarkTheme, useMediaQuery } from './hooks'; @@ -45,6 +46,7 @@ function MainLayout() { > + ); } diff --git a/frontend/src/assets/check-circle-filled.svg b/frontend/src/assets/check-circle-filled.svg new file mode 100644 index 00000000..d67782aa --- /dev/null +++ b/frontend/src/assets/check-circle-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/warn.svg b/frontend/src/assets/warn.svg new file mode 100644 index 00000000..fdac9ad1 --- /dev/null +++ b/frontend/src/assets/warn.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/UploadToast.tsx b/frontend/src/components/UploadToast.tsx new file mode 100644 index 00000000..1c200d29 --- /dev/null +++ b/frontend/src/components/UploadToast.tsx @@ -0,0 +1,241 @@ +import { useState } from 'react'; + +import { useDispatch, useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { + selectUploadTasks, + removeUploadTask, + clearCompletedTasks, +} from '../upload/uploadSlice'; +import ChevronDown from '../assets/chevron-down.svg'; +import CheckCircleFilled from '../assets/check-circle-filled.svg'; +import WarnIcon from '../assets/warn.svg'; + +const PROGRESS_RADIUS = 10; +const PROGRESS_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RADIUS; + +export default function UploadToast() { + const [collapsedTasks, setCollapsedTasks] = useState>( + {}, + ); + + const toggleTaskCollapse = (taskId: string) => { + setCollapsedTasks((prev) => ({ + ...prev, + [taskId]: !prev[taskId], + })); + }; + + const { t } = useTranslation(); + const dispatch = useDispatch(); + const uploadTasks = useSelector(selectUploadTasks); + + if (uploadTasks.length === 0) return null; + + const getStatusHeading = (status: string) => { + switch (status) { + case 'preparing': + return t('modals.uploadDoc.progress.wait'); + case 'uploading': + return t('modals.uploadDoc.progress.upload'); + case 'training': + return t('modals.uploadDoc.progress.training'); + case 'completed': + return t('modals.uploadDoc.progress.completed'); + case 'failed': + return t('attachments.uploadFailed'); + default: + return 'Preparing upload'; + } + }; + + return ( +
+ {uploadTasks.map((task) => { + const shouldShowProgress = [ + 'preparing', + 'uploading', + 'training', + ].includes(task.status); + const rawProgress = Math.min(Math.max(task.progress ?? 0, 0), 100); + const formattedProgress = Math.round(rawProgress); + const progressOffset = PROGRESS_CIRCUMFERENCE * (1 - rawProgress / 100); + const isCollapsed = collapsedTasks[task.id] ?? false; + + return ( +
+
+
+

+ {getStatusHeading(task.status)} +

+
+ + {(task.status === 'completed' || + task.status === 'failed') && ( + + )} +
+
+ +
+
+
+

+ {task.fileName} +

+ +
+ {shouldShowProgress && ( + + + + + )} + + {task.status === 'completed' && ( + + )} + + {task.status === 'failed' && ( + + )} +
+
+ + {task.status === 'failed' && task.errorMessage && ( + + {task.errorMessage} + + )} +
+
+
+
+ ); + })} + + {uploadTasks.some( + (task) => task.status === 'completed' || task.status === 'failed', + ) && ( + + )} +
+ ); +} diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx index 19170c2d..72e6a7f2 100644 --- a/frontend/src/upload/Upload.tsx +++ b/frontend/src/upload/Upload.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; +import { nanoid } from '@reduxjs/toolkit'; import { useDropzone } from 'react-dropzone'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; @@ -24,6 +25,8 @@ import { getIngestorSchema, IngestorOption, } from '../upload/types/ingestor'; +import { addUploadTask, updateUploadTask } from './uploadSlice'; + import { FormField, IngestorConfig, IngestorType } from './types/ingestor'; import { FilePicker } from '../components/FilePicker'; @@ -259,15 +262,8 @@ function Upload({ config: {}, })); - const [progress, setProgress] = useState<{ - type: 'UPLOAD' | 'TRAINING'; - percentage: number; - taskId?: string; - failed?: boolean; - }>(); - const { t } = useTranslation(); - const setTimeoutRef = useRef(null); + const dispatch = useDispatch(); const ingestorOptions: IngestorOption[] = IngestorFormSchemas.filter( (schema) => (schema.validate ? schema.validate() : true), @@ -279,188 +275,120 @@ function Upload({ })); const sourceDocs = useSelector(selectSourceDocs); - useEffect(() => { - if (setTimeoutRef.current) { - clearTimeout(setTimeoutRef.current); - } + + const resetUploaderState = useCallback(() => { + setIngestor({ type: null, name: '', config: {} }); + setfiles([]); + setSelectedFiles([]); + setSelectedFolders([]); + setShowAdvancedOptions(false); }, []); - function ProgressBar({ progressPercent }: { progressPercent: number }) { - return ( -
-
-
-
-
- {progressPercent}% -
- -
-
- ); - } + const handleTaskFailure = useCallback( + (clientTaskId: string, errorMessage?: string) => { + dispatch( + updateUploadTask({ + id: clientTaskId, + updates: { + status: 'failed', + errorMessage: errorMessage || t('attachments.uploadFailed'), + }, + }), + ); + }, + [dispatch, t], + ); - function Progress({ - title, - isCancellable = false, - isFailed = false, - isTraining = false, - }: { - title: string; - isCancellable?: boolean; - isFailed?: boolean; - isTraining?: boolean; - }) { - return ( -
-

- {isTraining && - (progress?.percentage === 100 - ? t('modals.uploadDoc.progress.completed') - : title)} - {!isTraining && title} -

-

{t('modals.uploadDoc.progress.wait')}

-

- {t('modals.uploadDoc.progress.tokenLimit')} -

- {/*

{progress?.percentage || 0}%

*/} - - {isTraining && - (progress?.percentage === 100 ? ( - - ) : ( - - ))} -
- ); - } + const trackTraining = useCallback( + (backendTaskId: string, clientTaskId: string) => { + let timeoutId: number | null = null; - function UploadProgress() { - return ; - } - - function TrainingProgress() { - const dispatch = useDispatch(); - - useEffect(() => { - let timeoutID: number | undefined; - - if ((progress?.percentage ?? 0) < 100) { - timeoutID = setTimeout(() => { - userService - .getTaskStatus(progress?.taskId as string, null) - .then((data) => data.json()) - .then((data) => { - if (data.status == 'SUCCESS') { - if (data.result.limited === true) { - getDocs(token).then((data) => { - dispatch(setSourceDocs(data)); - dispatch( - setSelectedDocs( - Array.isArray(data) && - data?.find( - (d: Doc) => d.type?.toLowerCase() === 'local', - ), - ), - ); - }); - setProgress( - (progress) => - progress && { - ...progress, - percentage: 100, - failed: true, - }, - ); - } else { - getDocs(token).then((data) => { - dispatch(setSourceDocs(data)); - const docIds = new Set( - (Array.isArray(sourceDocs) && - sourceDocs?.map((doc: Doc) => - doc.id ? doc.id : null, - )) || - [], - ); - if (data && Array.isArray(data)) { - data.map((updatedDoc: Doc) => { - if (updatedDoc.id && !docIds.has(updatedDoc.id)) { - // Select the doc not present in the intersection of current Docs and fetched data - dispatch(setSelectedDocs(updatedDoc)); - return; - } - }); - } - }); - setProgress( - (progress) => - progress && { - ...progress, - percentage: 100, - failed: false, - }, - ); - setIngestor({ type: null, name: '', config: {} }); - setfiles([]); - setProgress(undefined); - setModalState('INACTIVE'); - onSuccessfulUpload?.(); - } - } else if (data.status == 'PROGRESS') { - setProgress( - (progress) => - progress && { - ...progress, - percentage: data.result.current, - }, - ); + const poll = () => { + userService + .getTaskStatus(backendTaskId, null) + .then((response) => response.json()) + .then(async (data) => { + if (data.status === 'SUCCESS') { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; } - }); - }, 5000); - } - // cleanup - return () => { - if (timeoutID !== undefined) { - clearTimeout(timeoutID); - } + const docs = await getDocs(token); + dispatch(setSourceDocs(docs)); + + if (Array.isArray(docs)) { + const existingDocIds = new Set( + (Array.isArray(sourceDocs) ? sourceDocs : []) + .map((doc: Doc) => doc?.id) + .filter((id): id is string => Boolean(id)), + ); + const newDoc = docs.find( + (doc: Doc) => doc.id && !existingDocIds.has(doc.id), + ); + if (newDoc) { + dispatch(setSelectedDocs(newDoc)); + } + } + + if (data.result?.limited) { + dispatch( + updateUploadTask({ + id: clientTaskId, + updates: { + status: 'failed', + progress: 100, + errorMessage: t('modals.uploadDoc.progress.tokenLimit'), + }, + }), + ); + } else { + dispatch( + updateUploadTask({ + id: clientTaskId, + updates: { + status: 'completed', + progress: 100, + errorMessage: undefined, + }, + }), + ); + onSuccessfulUpload?.(); + } + } else if (data.status === 'FAILURE') { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } + handleTaskFailure(clientTaskId, data.result?.message); + } else if (data.status === 'PROGRESS') { + dispatch( + updateUploadTask({ + id: clientTaskId, + updates: { + status: 'training', + progress: Math.min(100, data.result?.current ?? 0), + }, + }), + ); + timeoutId = window.setTimeout(poll, 5000); + } else { + timeoutId = window.setTimeout(poll, 5000); + } + }) + .catch(() => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } + handleTaskFailure(clientTaskId); + }); }; - }, [progress, dispatch]); - return ( - - ); - } + + timeoutId = window.setTimeout(poll, 3000); + }, + [dispatch, handleTaskFailure, onSuccessfulUpload, sourceDocs, t, token], + ); const onDrop = useCallback( (acceptedFiles: File[]) => { @@ -483,7 +411,7 @@ function Upload({ const doNothing = () => undefined; - const uploadFile = () => { + const uploadFile = (clientTaskId: string) => { const formData = new FormData(); files.forEach((file) => { formData.append('file', file); @@ -491,34 +419,89 @@ function Upload({ formData.append('name', ingestor.name); formData.append('user', 'local'); + const apiHost = import.meta.env.VITE_API_HOST; const xhr = new XMLHttpRequest(); + + dispatch( + updateUploadTask({ + id: clientTaskId, + updates: { status: 'uploading', progress: 0 }, + }), + ); + xhr.upload.addEventListener('progress', (event) => { - const progress = +((event.loaded / event.total) * 100).toFixed(2); - setProgress({ type: 'UPLOAD', percentage: progress }); + if (!event.lengthComputable) return; + const progressPercentage = Number( + ((event.loaded / event.total) * 100).toFixed(2), + ); + dispatch( + updateUploadTask({ + id: clientTaskId, + updates: { progress: progressPercentage }, + }), + ); }); + xhr.onload = () => { - const { task_id } = JSON.parse(xhr.responseText); - setTimeoutRef.current = setTimeout(() => { - setProgress({ type: 'TRAINING', percentage: 0, taskId: task_id }); - }, 3000); + if (xhr.status >= 200 && xhr.status < 300) { + try { + const parsed = JSON.parse(xhr.responseText) as { task_id?: string }; + if (parsed.task_id) { + dispatch( + updateUploadTask({ + id: clientTaskId, + updates: { + taskId: parsed.task_id, + status: 'training', + progress: 0, + }, + }), + ); + trackTraining(parsed.task_id, clientTaskId); + } else { + dispatch( + updateUploadTask({ + id: clientTaskId, + updates: { status: 'completed', progress: 100 }, + }), + ); + onSuccessfulUpload?.(); + } + } catch (error) { + handleTaskFailure(clientTaskId); + } + } else { + handleTaskFailure(clientTaskId, xhr.statusText || undefined); + } }; - xhr.open('POST', `${apiHost + '/api/upload'}`); + + xhr.onerror = () => { + handleTaskFailure(clientTaskId); + }; + + xhr.open('POST', `${apiHost}/api/upload`); xhr.setRequestHeader('Authorization', `Bearer ${token}`); xhr.send(formData); }; - const uploadRemote = () => { - if (!ingestor.type) return; + const uploadRemote = (clientTaskId: string) => { + if (!ingestor.type) { + handleTaskFailure(clientTaskId); + return; + } + const formData = new FormData(); formData.append('name', ingestor.name); formData.append('user', 'local'); formData.append('source', ingestor.type as string); - let configData: any = {}; - const ingestorSchema = getIngestorSchema(ingestor.type as IngestorType); - if (!ingestorSchema) return; + if (!ingestorSchema) { + handleTaskFailure(clientTaskId); + return; + } + const schema: FormField[] = ingestorSchema.fields; const hasLocalFilePicker = schema.some( (field: FormField) => field.type === 'local_file_picker', @@ -530,11 +513,12 @@ function Upload({ (field: FormField) => field.type === 'google_drive_picker', ); + let configData: Record = { ...ingestor.config }; + if (hasLocalFilePicker) { files.forEach((file) => { formData.append('file', file); }); - configData = { ...ingestor.config }; } else if (hasRemoteFilePicker || hasGoogleDrivePicker) { const sessionToken = getSessionToken(ingestor.type as string); configData = { @@ -543,44 +527,122 @@ function Upload({ file_ids: selectedFiles, folder_ids: selectedFolders, }; - } else { - configData = { ...ingestor.config }; } formData.append('data', JSON.stringify(configData)); const apiHost: string = import.meta.env.VITE_API_HOST; - const xhr = new XMLHttpRequest(); - xhr.upload.addEventListener('progress', (event: ProgressEvent) => { - if (event.lengthComputable) { - const progressPercentage = +( - (event.loaded / event.total) * - 100 - ).toFixed(2); - setProgress({ type: 'UPLOAD', percentage: progressPercentage }); - } - }); - xhr.onload = () => { - const response = JSON.parse(xhr.responseText) as { task_id: string }; - setTimeoutRef.current = window.setTimeout(() => { - setProgress({ - type: 'TRAINING', - percentage: 0, - taskId: response.task_id, - }); - }, 3000); - }; - const endpoint = ingestor.type === 'local_file' ? `${apiHost}/api/upload` : `${apiHost}/api/remote`; + const xhr = new XMLHttpRequest(); + + dispatch( + updateUploadTask({ + id: clientTaskId, + updates: { status: 'uploading', progress: 0 }, + }), + ); + + xhr.upload.addEventListener('progress', (event: ProgressEvent) => { + if (!event.lengthComputable) return; + const progressPercentage = Number( + ((event.loaded / event.total) * 100).toFixed(2), + ); + dispatch( + updateUploadTask({ + id: clientTaskId, + updates: { progress: progressPercentage }, + }), + ); + }); + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const response = JSON.parse(xhr.responseText) as { task_id?: string }; + if (response.task_id) { + dispatch( + updateUploadTask({ + id: clientTaskId, + updates: { + taskId: response.task_id, + status: 'training', + progress: 0, + }, + }), + ); + trackTraining(response.task_id, clientTaskId); + } else { + dispatch( + updateUploadTask({ + id: clientTaskId, + updates: { status: 'completed', progress: 100 }, + }), + ); + onSuccessfulUpload?.(); + } + } catch (error) { + handleTaskFailure(clientTaskId); + } + } else { + handleTaskFailure(clientTaskId, xhr.statusText || undefined); + } + }; + + xhr.onerror = () => { + handleTaskFailure(clientTaskId); + }; + xhr.open('POST', endpoint); xhr.setRequestHeader('Authorization', `Bearer ${token}`); xhr.send(formData); }; + const handleClose = useCallback(() => { + resetUploaderState(); + setModalState('INACTIVE'); + close(); + }, [close, resetUploaderState, setModalState]); + + const handleUpload = () => { + if (!ingestor.type) return; + + const ingestorSchemaForUpload = getIngestorSchema( + ingestor.type as IngestorType, + ); + if (!ingestorSchemaForUpload) return; + + const schema: FormField[] = ingestorSchemaForUpload.fields; + const hasLocalFilePicker = schema.some( + (field: FormField) => field.type === 'local_file_picker', + ); + + const displayName = + ingestor.name?.trim() || files[0]?.name || t('modals.uploadDoc.label'); + + const clientTaskId = nanoid(); + + dispatch( + addUploadTask({ + id: clientTaskId, + fileName: displayName, + progress: 0, + status: 'preparing', + }), + ); + + if (hasLocalFilePicker) { + uploadFile(clientTaskId); + } else { + uploadRemote(clientTaskId); + } + + handleClose(); + }; + const { getRootProps, getInputProps } = useDropzone({ onDrop, multiple: true, @@ -741,14 +803,12 @@ function Upload({ ); }; - let view; - - if (progress?.type === 'UPLOAD') { - view = ; - } else if (progress?.type === 'TRAINING') { - view = ; - } else { - view = ( + return ( +
{!ingestor.type && (

@@ -816,23 +876,7 @@ function Upload({

{activeTab && ingestor.type && (
- ); - } - - return ( - { - close(); - setIngestor({ type: null, name: '', config: {} }); - setfiles([]); - setModalState('INACTIVE'); - }} - className="max-h-[90vh] w-11/12 sm:max-h-none sm:w-auto sm:min-w-[600px] md:min-w-[700px]" - contentClassName="max-h-[80vh] sm:max-h-none" - > - {view} ); } diff --git a/frontend/src/upload/uploadSlice.ts b/frontend/src/upload/uploadSlice.ts index 732c69bc..5d18ebb2 100644 --- a/frontend/src/upload/uploadSlice.ts +++ b/frontend/src/upload/uploadSlice.ts @@ -10,12 +10,30 @@ export interface Attachment { token_count?: number; } +export type UploadTaskStatus = + | 'preparing' + | 'uploading' + | 'training' + | 'completed' + | 'failed'; + +export interface UploadTask { + id: string; + fileName: string; + progress: number; + status: UploadTaskStatus; + taskId?: string; + errorMessage?: string; +} + interface UploadState { attachments: Attachment[]; + tasks: UploadTask[]; } const initialState: UploadState = { attachments: [], + tasks: [], }; export const uploadSlice = createSlice({ @@ -52,6 +70,37 @@ export const uploadSlice = createSlice({ (att) => att.status === 'uploading' || att.status === 'processing', ); }, + addUploadTask: (state, action: PayloadAction) => { + state.tasks.unshift(action.payload); + }, + updateUploadTask: ( + state, + action: PayloadAction<{ + id: string; + updates: Partial; + }>, + ) => { + const index = state.tasks.findIndex( + (task) => task.id === action.payload.id, + ); + if (index !== -1) { + state.tasks[index] = { + ...state.tasks[index], + ...action.payload.updates, + }; + } + }, + removeUploadTask: (state, action: PayloadAction) => { + state.tasks = state.tasks.filter((task) => task.id !== action.payload); + }, + clearCompletedTasks: (state) => { + state.tasks = state.tasks.filter( + (task) => + task.status === 'uploading' || + task.status === 'training' || + task.status === 'preparing', + ); + }, }, }); @@ -60,10 +109,15 @@ export const { updateAttachment, removeAttachment, clearAttachments, + addUploadTask, + updateUploadTask, + removeUploadTask, + clearCompletedTasks, } = uploadSlice.actions; export const selectAttachments = (state: RootState) => state.upload.attachments; export const selectCompletedAttachments = (state: RootState) => state.upload.attachments.filter((att) => att.status === 'completed'); +export const selectUploadTasks = (state: RootState) => state.upload.tasks; export default uploadSlice.reducer;