(feat:upload) new toast UI

This commit is contained in:
ManishMadan2882
2025-10-03 17:08:39 +05:30
parent 55ff7dd640
commit f09fa8231a
6 changed files with 593 additions and 262 deletions

View File

@@ -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() {
>
<Outlet />
</div>
<UploadToast />
</div>
);
}

View File

@@ -0,0 +1,3 @@
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.5C17.523 2.5 22 6.977 22 12.5C22 18.023 17.523 22.5 12 22.5C6.477 22.5 2 18.023 2 12.5C2 6.977 6.477 2.5 12 2.5ZM15.22 9.47L10.75 13.94L8.78 11.97C8.63783 11.8375 8.44978 11.7654 8.25548 11.7688C8.06118 11.7723 7.87579 11.851 7.73838 11.9884C7.60097 12.1258 7.52225 12.3112 7.51883 12.5055C7.5154 12.6998 7.58752 12.8878 7.72 13.03L10.22 15.53C10.3606 15.6705 10.5512 15.7493 10.75 15.7493C10.9488 15.7493 11.1394 15.6705 11.28 15.53L16.28 10.53C16.3537 10.4613 16.4128 10.3785 16.4538 10.2865C16.4948 10.1945 16.5168 10.0952 16.5186 9.99452C16.5204 9.89382 16.5018 9.79379 16.4641 9.7004C16.4264 9.60701 16.3703 9.52218 16.299 9.45096C16.2278 9.37974 16.143 9.3236 16.0496 9.28588C15.9562 9.24816 15.8562 9.22963 15.7555 9.23141C15.6548 9.23318 15.5555 9.25523 15.4635 9.29622C15.3715 9.33721 15.2887 9.39631 15.22 9.47Z" fill="#0C9D35"/>
</svg>

After

Width:  |  Height:  |  Size: 958 B

View File

@@ -0,0 +1,3 @@
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 1.83989C16.5202 2.71758 17.7826 3.97997 18.6603 5.50017C19.538 7.02038 20 8.74483 20 10.5002C20 12.2556 19.5379 13.98 18.6602 15.5002C17.7825 17.0204 16.5201 18.2828 14.9999 19.1605C13.4797 20.0381 11.7552 20.5002 9.99984 20.5001C8.24446 20.5001 6.52002 20.038 4.99984 19.1603C3.47965 18.2826 2.21729 17.0202 1.33963 15.5C0.46198 13.9797 -4.45897e-05 12.2553 3.22765e-09 10.4999L0.00500012 10.1759C0.0610032 8.44888 0.563548 6.76585 1.46364 5.29089C2.36373 3.81592 3.63065 2.59934 5.14089 1.75977C6.65113 0.920205 8.35315 0.486289 10.081 0.50033C11.8089 0.514371 13.5036 0.97589 15 1.83989ZM10 13.4999C9.73478 13.4999 9.48043 13.6052 9.29289 13.7928C9.10536 13.9803 9 14.2347 9 14.4999V14.5099C9 14.7751 9.10536 15.0295 9.29289 15.217C9.48043 15.4045 9.73478 15.5099 10 15.5099C10.2652 15.5099 10.5196 15.4045 10.7071 15.217C10.8946 15.0295 11 14.7751 11 14.5099V14.4999C11 14.2347 10.8946 13.9803 10.7071 13.7928C10.5196 13.6052 10.2652 13.4999 10 13.4999ZM10 6.49989C9.73478 6.49989 9.48043 6.60525 9.29289 6.79279C9.10536 6.98032 9 7.23468 9 7.49989V11.4999C9 11.7651 9.10536 12.0195 9.29289 12.207C9.48043 12.3945 9.73478 12.4999 10 12.4999C10.2652 12.4999 10.5196 12.3945 10.7071 12.207C10.8946 12.0195 11 11.7651 11 11.4999V7.49989C11 7.23468 10.8946 6.98032 10.7071 6.79279C10.5196 6.60525 10.2652 6.49989 10 6.49989Z" fill="#EA4335"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -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<Record<string, boolean>>(
{},
);
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 (
<div className="fixed right-4 bottom-4 z-50 flex max-w-md flex-col gap-2">
{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 (
<div
key={task.id}
className={`w-[271px] overflow-hidden rounded-2xl border border-[#00000021] shadow-[0px_24px_48px_0px_#00000029] transition-all duration-300 ${
task.status === 'completed'
? 'bg-[#FBFBFB] dark:bg-[#26272E]'
: task.status === 'failed'
? 'bg-[#FBFBFB] dark:bg-[#26272E]'
: 'bg-[#FBFBFB] dark:bg-[#26272E]'
}`}
>
<div className="flex flex-col">
<div
className={`flex items-center justify-between px-4 py-3 ${
task.status !== 'failed'
? 'bg-[#FBF2FE] dark:bg-transparent'
: ''
}`}
>
<h3 className="font-inter text-[14px] leading-[16.5px] font-medium text-black dark:text-[#DCDCDC]">
{getStatusHeading(task.status)}
</h3>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => toggleTaskCollapse(task.id)}
aria-label={
isCollapsed
? 'Expand upload details'
: 'Collapse upload details'
}
className="flex h-8 items-center justify-center p-0 text-black opacity-70 transition-opacity hover:opacity-100 dark:text-white"
>
<img
src={ChevronDown}
alt=""
className={`h-4 w-4 transform transition-transform duration-200 dark:invert ${
isCollapsed ? '' : 'rotate-180'
}`}
/>
</button>
{(task.status === 'completed' ||
task.status === 'failed') && (
<button
type="button"
onClick={() => dispatch(removeUploadTask(task.id))}
className="flex h-8 items-center justify-center p-0 text-black opacity-70 transition-opacity hover:opacity-100 dark:text-white"
aria-label="Dismiss upload toast"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
>
<path
d="M18 6L6 18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M6 6L18 18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</div>
</div>
<div
className="grid overflow-hidden transition-[grid-template-rows] duration-300 ease-out"
style={{ gridTemplateRows: isCollapsed ? '0fr' : '1fr' }}
>
<div
className={`min-h-0 overflow-hidden transition-opacity duration-300 ${
isCollapsed ? 'opacity-0' : 'opacity-100'
}`}
>
<div className="flex items-center justify-between px-5 py-3">
<p
className="font-inter max-w-[200px] truncate text-[13px] leading-[16.5px] font-normal text-black dark:text-[#B7BAB8]"
title={task.fileName}
>
{task.fileName}
</p>
<div className="flex items-center gap-2">
{shouldShowProgress && (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="h-6 w-6 flex-shrink-0 text-[#7D54D1]"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={formattedProgress}
aria-label={`Upload progress ${formattedProgress}%`}
>
<circle
className="text-gray-300 dark:text-gray-700"
stroke="currentColor"
strokeWidth="2"
cx="12"
cy="12"
r={PROGRESS_RADIUS}
fill="none"
/>
<circle
className="text-[#7D54D1]"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray={PROGRESS_CIRCUMFERENCE}
strokeDashoffset={progressOffset}
cx="12"
cy="12"
r={PROGRESS_RADIUS}
fill="none"
transform="rotate(-90 12 12)"
/>
</svg>
)}
{task.status === 'completed' && (
<img
src={CheckCircleFilled}
alt=""
className="h-6 w-6 flex-shrink-0"
aria-hidden="true"
/>
)}
{task.status === 'failed' && (
<img
src={WarnIcon}
alt=""
className="h-6 w-6 flex-shrink-0"
aria-hidden="true"
/>
)}
</div>
</div>
{task.status === 'failed' && task.errorMessage && (
<span className="block px-5 pb-3 text-xs text-red-500">
{task.errorMessage}
</span>
)}
</div>
</div>
</div>
</div>
);
})}
{uploadTasks.some(
(task) => task.status === 'completed' || task.status === 'failed',
) && (
<button
onClick={() => dispatch(clearCompletedTasks())}
className="mt-1 mr-1 text-right text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Clear
</button>
)}
</div>
);
}

View File

@@ -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<number | null>(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 (
<div className="my-8 flex h-full w-full items-center justify-center">
<div className="relative h-32 w-32 rounded-full">
<div className="absolute inset-0 rounded-full shadow-[0_0_10px_2px_rgba(0,0,0,0.3)_inset] dark:shadow-[0_0_10px_2px_rgba(0,0,0,0.3)_inset]"></div>
<div
className={`absolute inset-0 rounded-full ${progressPercent === 100 ? 'bg-linear-to-r from-white to-gray-400 shadow-xl shadow-lime-300/50 dark:bg-linear-to-br dark:from-gray-500 dark:to-gray-300 dark:shadow-lime-300/50' : 'shadow-[0_4px_0_#7D54D1] dark:shadow-[0_4px_0_#7D54D1]'}`}
style={{
animation: `${progressPercent === 100 ? 'none' : 'rotate 2s linear infinite'}`,
}}
></div>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-bold">{progressPercent}%</span>
</div>
<style>
{`@keyframes rotate {
0% { transform: rotate(0deg); }
100%{ transform: rotate(360deg); }
}`}
</style>
</div>
</div>
);
}
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 (
<div className="text-gray-2000 dark:text-bright-gray mt-5 flex flex-col items-center gap-2">
<p className="text-gra text-xl tracking-[0.15px]">
{isTraining &&
(progress?.percentage === 100
? t('modals.uploadDoc.progress.completed')
: title)}
{!isTraining && title}
</p>
<p className="text-sm">{t('modals.uploadDoc.progress.wait')}</p>
<p className={`ml-5 text-xl text-red-400 ${isFailed ? '' : 'hidden'}`}>
{t('modals.uploadDoc.progress.tokenLimit')}
</p>
{/* <p className="mt-10 text-2xl">{progress?.percentage || 0}%</p> */}
<ProgressBar progressPercent={progress?.percentage || 0} />
{isTraining &&
(progress?.percentage === 100 ? (
<button
onClick={() => {
setIngestor({ type: null, name: '', config: {} });
setfiles([]);
setProgress(undefined);
setModalState('INACTIVE');
}}
className="h-[42px] cursor-pointer rounded-3xl bg-[#7D54D1] px-[28px] py-[6px] text-sm text-white shadow-lg hover:bg-[#6F3FD1]"
>
{t('modals.uploadDoc.start')}
</button>
) : (
<button
className="ml-2 h-[42px] cursor-pointer rounded-3xl bg-[#7D54D14D] px-[28px] py-[6px] text-sm text-white shadow-lg"
disabled
>
{t('modals.uploadDoc.wait')}
</button>
))}
</div>
);
}
const trackTraining = useCallback(
(backendTaskId: string, clientTaskId: string) => {
let timeoutId: number | null = null;
function UploadProgress() {
return <Progress title={t('modals.uploadDoc.progress.upload')}></Progress>;
}
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 (
<Progress
title={t('modals.uploadDoc.progress.training')}
isCancellable={progress?.percentage === 100}
isFailed={progress?.failed === true}
isTraining={true}
></Progress>
);
}
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<string, unknown> = { ...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({
</div>
);
};
let view;
if (progress?.type === 'UPLOAD') {
view = <UploadProgress></UploadProgress>;
} else if (progress?.type === 'TRAINING') {
view = <TrainingProgress></TrainingProgress>;
} else {
view = (
return (
<WrapperModal
close={handleClose}
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"
>
<div className="flex w-full flex-col gap-6">
{!ingestor.type && (
<p className="font-inter text-left text-[20px] leading-[28px] font-semibold tracking-[0.15px] text-[#18181B] dark:text-[#ECECF1]">
@@ -816,23 +876,7 @@ function Upload({
<div className="flex justify-end gap-4">
{activeTab && ingestor.type && (
<button
onClick={() => {
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',
);
if (hasLocalFilePicker) {
uploadFile();
} else {
uploadRemote();
}
}}
onClick={handleUpload}
disabled={isUploadDisabled()}
className={`rounded-3xl px-4 py-2 text-[14px] font-medium ${
isUploadDisabled()
@@ -845,22 +889,6 @@ function Upload({
)}
</div>
</div>
);
}
return (
<WrapperModal
isPerformingTask={progress !== undefined && progress.percentage < 100}
close={() => {
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}
</WrapperModal>
);
}

View File

@@ -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<UploadTask>) => {
state.tasks.unshift(action.payload);
},
updateUploadTask: (
state,
action: PayloadAction<{
id: string;
updates: Partial<UploadTask>;
}>,
) => {
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<string>) => {
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;