diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9882ca9c..0a8c22f1 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/ConnectorAuth.tsx b/frontend/src/components/ConnectorAuth.tsx index 2c56a6d6..a60d293c 100644 --- a/frontend/src/components/ConnectorAuth.tsx +++ b/frontend/src/components/ConnectorAuth.tsx @@ -1,5 +1,6 @@ import React, { useRef } from 'react'; import { useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; import { useDarkTheme } from '../hooks'; import { selectToken } from '../preferences/preferenceSlice'; @@ -24,6 +25,7 @@ const ConnectorAuth: React.FC = ({ onDisconnect, errorMessage, }) => { + const { t } = useTranslation(); const token = useSelector(selectToken); const [isDarkTheme] = useDarkTheme(); const completedRef = useRef(false); @@ -47,12 +49,16 @@ const ConnectorAuth: React.FC = ({ cleanup(); onSuccess({ session_token: event.data.session_token, - user_email: event.data.user_email || 'Connected User', + user_email: + event.data.user_email || + t('modals.uploadDoc.connectors.auth.connectedUser'), }); } else if (errorProvider) { completedRef.current = true; cleanup(); - onError(event.data.error || 'Authentication failed'); + onError( + event.data.error || t('modals.uploadDoc.connectors.auth.authFailed'), + ); } }; @@ -71,13 +77,15 @@ const ConnectorAuth: React.FC = ({ if (!authResponse.ok) { throw new Error( - `Failed to get authorization URL: ${authResponse.status}`, + `${t('modals.uploadDoc.connectors.auth.authUrlFailed')}: ${authResponse.status}`, ); } const authData = await authResponse.json(); if (!authData.success || !authData.authorization_url) { - throw new Error(authData.error || 'Failed to get authorization URL'); + throw new Error( + authData.error || t('modals.uploadDoc.connectors.auth.authUrlFailed'), + ); } const authWindow = window.open( @@ -86,9 +94,7 @@ const ConnectorAuth: React.FC = ({ 'width=500,height=600,scrollbars=yes,resizable=yes', ); if (!authWindow) { - throw new Error( - 'Failed to open authentication window. Please allow popups.', - ); + throw new Error(t('modals.uploadDoc.connectors.auth.popupBlocked')); } window.addEventListener('message', handleAuthMessage as any); @@ -98,13 +104,17 @@ const ConnectorAuth: React.FC = ({ clearInterval(checkClosed); window.removeEventListener('message', handleAuthMessage as any); if (!completedRef.current) { - onError('Authentication was cancelled'); + onError(t('modals.uploadDoc.connectors.auth.authCancelled')); } } }, 1000); intervalRef.current = checkClosed; } catch (error) { - onError(error instanceof Error ? error.message : 'Authentication failed'); + onError( + error instanceof Error + ? error.message + : t('modals.uploadDoc.connectors.auth.authFailed'), + ); } }; @@ -147,14 +157,18 @@ const ConnectorAuth: React.FC = ({ d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" /> - Connected as {userEmail} + + {t('modals.uploadDoc.connectors.auth.connectedAs', { + email: userEmail, + })} + {onDisconnect && ( )} diff --git a/frontend/src/components/GoogleDrivePicker.tsx b/frontend/src/components/GoogleDrivePicker.tsx index 1908cf3d..be7f178e 100644 --- a/frontend/src/components/GoogleDrivePicker.tsx +++ b/frontend/src/components/GoogleDrivePicker.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import useDrivePicker from 'react-google-drive-picker'; import ConnectorAuth from './ConnectorAuth'; @@ -26,6 +27,7 @@ const GoogleDrivePicker: React.FC = ({ token, onSelectionChange, }) => { + const { t } = useTranslation(); const [selectedFiles, setSelectedFiles] = useState([]); const [selectedFolders, setSelectedFolders] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -66,14 +68,19 @@ const GoogleDrivePicker: React.FC = ({ if (!validateResponse.ok) { setIsConnected(false); - setAuthError('Session expired. Please reconnect to Google Drive.'); + setAuthError( + t('modals.uploadDoc.connectors.googleDrive.sessionExpired'), + ); setIsValidating(false); return false; } const validateData = await validateResponse.json(); if (validateData.success) { - setUserEmail(validateData.user_email || 'Connected User'); + setUserEmail( + validateData.user_email || + t('modals.uploadDoc.connectors.auth.connectedUser'), + ); setIsConnected(true); setAuthError(''); setAccessToken(validateData.access_token || null); @@ -83,14 +90,14 @@ const GoogleDrivePicker: React.FC = ({ setIsConnected(false); setAuthError( validateData.error || - 'Session expired. Please reconnect your account.', + t('modals.uploadDoc.connectors.googleDrive.sessionExpiredGeneric'), ); setIsValidating(false); return false; } } catch (error) { console.error('Error validating session:', error); - setAuthError('Failed to validate session. Please reconnect.'); + setAuthError(t('modals.uploadDoc.connectors.googleDrive.validateFailed')); setIsConnected(false); setIsValidating(false); return false; @@ -103,15 +110,13 @@ const GoogleDrivePicker: React.FC = ({ const sessionToken = getSessionToken('google_drive'); if (!sessionToken) { - setAuthError('No valid session found. Please reconnect to Google Drive.'); + setAuthError(t('modals.uploadDoc.connectors.googleDrive.noSession')); setIsLoading(false); return; } if (!accessToken) { - setAuthError( - 'No access token available. Please reconnect to Google Drive.', - ); + setAuthError(t('modals.uploadDoc.connectors.googleDrive.noAccessToken')); setIsLoading(false); return; } @@ -193,7 +198,7 @@ const GoogleDrivePicker: React.FC = ({ }); } catch (error) { console.error('Error opening picker:', error); - setAuthError('Failed to open file picker. Please try again.'); + setAuthError(t('modals.uploadDoc.connectors.googleDrive.pickerFailed')); setIsLoading(false); } }; @@ -264,9 +269,12 @@ const GoogleDrivePicker: React.FC = ({ <> { - setUserEmail(data.user_email || 'Connected User'); + setUserEmail( + data.user_email || + t('modals.uploadDoc.connectors.auth.connectedUser'), + ); setIsConnected(true); setAuthError(''); @@ -289,26 +297,34 @@ const GoogleDrivePicker: React.FC = ({
-

Selected Files

+

+ {t('modals.uploadDoc.connectors.googleDrive.selectedFiles')} +

{selectedFiles.length === 0 && selectedFolders.length === 0 ? (

- No files or folders selected + {t( + 'modals.uploadDoc.connectors.googleDrive.noFilesSelected', + )}

) : (
{selectedFolders.length > 0 && (

- Folders + {t('modals.uploadDoc.connectors.googleDrive.folders')}

{selectedFolders.map((folder) => (
= ({ > Folder @@ -337,7 +355,9 @@ const GoogleDrivePicker: React.FC = ({ }} className="ml-2 text-sm text-red-500 hover:text-red-700" > - Remove + {t( + 'modals.uploadDoc.connectors.googleDrive.remove', + )}
))} @@ -347,7 +367,7 @@ const GoogleDrivePicker: React.FC = ({ {selectedFiles.length > 0 && (

- Files + {t('modals.uploadDoc.connectors.googleDrive.files')}

{selectedFiles.map((file) => (
= ({ > File @@ -375,7 +397,9 @@ const GoogleDrivePicker: React.FC = ({ }} className="ml-2 text-sm text-red-500 hover:text-red-700" > - Remove + {t( + 'modals.uploadDoc.connectors.googleDrive.remove', + )}
))} diff --git a/frontend/src/components/UploadToast.tsx b/frontend/src/components/UploadToast.tsx new file mode 100644 index 00000000..dbd23f64 --- /dev/null +++ b/frontend/src/components/UploadToast.tsx @@ -0,0 +1,229 @@ +import { useState } from 'react'; + +import { useDispatch, useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { selectUploadTasks, dismissUploadTask } 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); + + 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.upload'); + case 'completed': + return t('modals.uploadDoc.progress.completed'); + case 'failed': + return t('attachments.uploadFailed'); + default: + return t('modals.uploadDoc.progress.preparing'); + } + }; + + return ( +
+ {uploadTasks + .filter((task) => !task.dismissed) + .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.fileName} +

+ +
+ {shouldShowProgress && ( + + + + + )} + + {task.status === 'completed' && ( + + )} + + {task.status === 'failed' && ( + + )} +
+
+ + {task.status === 'failed' && task.errorMessage && ( + + {task.errorMessage} + + )} +
+
+
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 35419534..25a8fb9e 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -229,6 +229,9 @@ "uploadDoc": { "label": "Upload new document", "select": "Choose how to upload your document to DocsGPT", + "selectSource": "Select the way to add your source", + "selectedFiles": "Selected Files", + "noFilesSelected": "No files selected", "file": "Upload from device", "back": "Back", "wait": "Please wait ...", @@ -257,13 +260,74 @@ }, "progress": { "upload": "Upload is in progress", - "training": "Training is in progress", - "completed": "Training completed", + "training": "Upload is in progress", + "completed": "Upload completed", "wait": "This may take several minutes", - "tokenLimit": "Over the token limit, please consider uploading smaller document" + "preparing": "Preparing upload", + "tokenLimit": "Over the token limit, please consider uploading smaller document", + "expandDetails": "Expand upload details", + "collapseDetails": "Collapse upload details", + "dismiss": "Dismiss upload toast", + "uploadProgress": "Upload progress {{progress}}%", + "clear": "Clear" }, "showAdvanced": "Show advanced options", - "hideAdvanced": "Hide advanced options" + "hideAdvanced": "Hide advanced options", + "ingestors": { + "local_file": { + "label": "Upload File", + "heading": "Upload new document" + }, + "crawler": { + "label": "Crawler", + "heading": "Add content with Web Crawler" + }, + "url": { + "label": "Link", + "heading": "Add content from URL" + }, + "github": { + "label": "GitHub", + "heading": "Add content from GitHub" + }, + "reddit": { + "label": "Reddit", + "heading": "Add content from Reddit" + }, + "google_drive": { + "label": "Google Drive", + "heading": "Upload from Google Drive" + } + }, + "connectors": { + "auth": { + "connectedUser": "Connected User", + "authFailed": "Authentication failed", + "authUrlFailed": "Failed to get authorization URL", + "popupBlocked": "Failed to open authentication window. Please allow popups.", + "authCancelled": "Authentication was cancelled", + "connectedAs": "Connected as {{email}}", + "disconnect": "Disconnect" + }, + "googleDrive": { + "connect": "Connect to Google Drive", + "sessionExpired": "Session expired. Please reconnect to Google Drive.", + "sessionExpiredGeneric": "Session expired. Please reconnect your account.", + "validateFailed": "Failed to validate session. Please reconnect.", + "noSession": "No valid session found. Please reconnect to Google Drive.", + "noAccessToken": "No access token available. Please reconnect to Google Drive.", + "pickerFailed": "Failed to open file picker. Please try again.", + "selectedFiles": "Selected Files", + "selectFiles": "Select Files", + "loading": "Loading...", + "noFilesSelected": "No files or folders selected", + "folders": "Folders", + "files": "Files", + "remove": "Remove", + "folderAlt": "Folder", + "fileAlt": "File" + } + } }, "createAPIKey": { "label": "Create New API Key", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 4fb761f5..cedc3232 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -192,6 +192,9 @@ "uploadDoc": { "label": "Subir nuevo documento", "select": "Elige cómo cargar tu documento en DocsGPT", + "selectSource": "Selecciona la forma de agregar tu fuente", + "selectedFiles": "Archivos Seleccionados", + "noFilesSelected": "No hay archivos seleccionados", "file": "Subir desde el dispositivo", "back": "Atrás", "wait": "Por favor espera ...", @@ -220,13 +223,74 @@ }, "progress": { "upload": "Subida en progreso", - "training": "Entrenamiento en progreso", - "completed": "Entrenamiento completado", + "training": "Subida en progreso", + "completed": "Subida completada", "wait": "Esto puede tardar varios minutos", - "tokenLimit": "Excede el límite de tokens, considere cargar un documento más pequeño" + "preparing": "Preparando subida", + "tokenLimit": "Excede el límite de tokens, considere cargar un documento más pequeño", + "expandDetails": "Expandir detalles de subida", + "collapseDetails": "Contraer detalles de subida", + "dismiss": "Descartar notificación de subida", + "uploadProgress": "Progreso de subida {{progress}}%", + "clear": "Limpiar" }, "showAdvanced": "Mostrar opciones avanzadas", - "hideAdvanced": "Ocultar opciones avanzadas" + "hideAdvanced": "Ocultar opciones avanzadas", + "ingestors": { + "local_file": { + "label": "Subir archivo", + "heading": "Subir nuevo documento" + }, + "crawler": { + "label": "Rastreador", + "heading": "Agregar contenido con rastreador web" + }, + "url": { + "label": "Enlace", + "heading": "Agregar contenido desde URL" + }, + "github": { + "label": "GitHub", + "heading": "Agregar contenido desde GitHub" + }, + "reddit": { + "label": "Reddit", + "heading": "Agregar contenido desde Reddit" + }, + "google_drive": { + "label": "Google Drive", + "heading": "Subir desde Google Drive" + } + }, + "connectors": { + "auth": { + "connectedUser": "Usuario Conectado", + "authFailed": "Autenticación fallida", + "authUrlFailed": "Error al obtener la URL de autorización", + "popupBlocked": "Error al abrir la ventana de autenticación. Por favor, permita ventanas emergentes.", + "authCancelled": "Autenticación cancelada", + "connectedAs": "Conectado como {{email}}", + "disconnect": "Desconectar" + }, + "googleDrive": { + "connect": "Conectar a Google Drive", + "sessionExpired": "Sesión expirada. Por favor, reconecte a Google Drive.", + "sessionExpiredGeneric": "Sesión expirada. Por favor, reconecte su cuenta.", + "validateFailed": "Error al validar la sesión. Por favor, reconecte.", + "noSession": "No se encontró una sesión válida. Por favor, reconecte a Google Drive.", + "noAccessToken": "No hay token de acceso disponible. Por favor, reconecte a Google Drive.", + "pickerFailed": "Error al abrir el selector de archivos. Por favor, inténtelo de nuevo.", + "selectedFiles": "Archivos Seleccionados", + "selectFiles": "Seleccionar Archivos", + "loading": "Cargando...", + "noFilesSelected": "No hay archivos o carpetas seleccionados", + "folders": "Carpetas", + "files": "Archivos", + "remove": "Eliminar", + "folderAlt": "Carpeta", + "fileAlt": "Archivo" + } + } }, "createAPIKey": { "label": "Crear Nueva Clave de API", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index a805eecf..1072b17c 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -192,6 +192,9 @@ "uploadDoc": { "label": "新しい文書をアップロードする", "select": "ドキュメントを DocsGPT にアップロードする方法を選択します", + "selectSource": "ソースを追加する方法を選択してください", + "selectedFiles": "選択されたファイル", + "noFilesSelected": "ファイルが選択されていません", "file": "デバイスからアップロード", "back": "戻る", "wait": "お待ちください ...", @@ -220,13 +223,74 @@ }, "progress": { "upload": "アップロード中", - "training": "トレーニング中", - "completed": "トレーニング完了", + "training": "アップロード中", + "completed": "アップロード完了", "wait": "数分かかる場合があります", - "tokenLimit": "トークン制限を超えています。より小さいドキュメントをアップロードしてください" + "preparing": "アップロードを準備中", + "tokenLimit": "トークン制限を超えています。より小さいドキュメントをアップロードしてください", + "expandDetails": "アップロードの詳細を展開", + "collapseDetails": "アップロードの詳細を折りたたむ", + "dismiss": "アップロード通知を閉じる", + "uploadProgress": "アップロード進行状況 {{progress}}%", + "clear": "クリア" }, "showAdvanced": "詳細オプションを表示", - "hideAdvanced": "詳細オプションを非表示" + "hideAdvanced": "詳細オプションを非表示", + "ingestors": { + "local_file": { + "label": "ファイルをアップロード", + "heading": "新しいドキュメントをアップロード" + }, + "crawler": { + "label": "クローラー", + "heading": "Webクローラーでコンテンツを追加" + }, + "url": { + "label": "リンク", + "heading": "URLからコンテンツを追加" + }, + "github": { + "label": "GitHub", + "heading": "GitHubからコンテンツを追加" + }, + "reddit": { + "label": "Reddit", + "heading": "Redditからコンテンツを追加" + }, + "google_drive": { + "label": "Google Drive", + "heading": "Google Driveからアップロード" + } + }, + "connectors": { + "auth": { + "connectedUser": "接続されたユーザー", + "authFailed": "認証に失敗しました", + "authUrlFailed": "認証URLの取得に失敗しました", + "popupBlocked": "認証ウィンドウを開けませんでした。ポップアップを許可してください。", + "authCancelled": "認証がキャンセルされました", + "connectedAs": "{{email}}として接続", + "disconnect": "切断" + }, + "googleDrive": { + "connect": "Google Driveに接続", + "sessionExpired": "セッションが期限切れです。Google Driveに再接続してください。", + "sessionExpiredGeneric": "セッションが期限切れです。アカウントに再接続してください。", + "validateFailed": "セッションの検証に失敗しました。再接続してください。", + "noSession": "有効なセッションが見つかりません。Google Driveに再接続してください。", + "noAccessToken": "アクセストークンが利用できません。Google Driveに再接続してください。", + "pickerFailed": "ファイルピッカーを開けませんでした。もう一度お試しください。", + "selectedFiles": "選択されたファイル", + "selectFiles": "ファイルを選択", + "loading": "読み込み中...", + "noFilesSelected": "ファイルまたはフォルダが選択されていません", + "folders": "フォルダ", + "files": "ファイル", + "remove": "削除", + "folderAlt": "フォルダ", + "fileAlt": "ファイル" + } + } }, "createAPIKey": { "label": "新しいAPIキーを作成", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index f2b2111e..0ca63ec5 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -192,6 +192,9 @@ "uploadDoc": { "label": "Загрузить новый документ", "select": "Выберите способ загрузки документа в DocsGPT", + "selectSource": "Выберите способ добавления источника", + "selectedFiles": "Выбранные файлы", + "noFilesSelected": "Файлы не выбраны", "file": "Загрузить с устройства", "back": "Назад", "wait": "Пожалуйста, подождите...", @@ -220,13 +223,74 @@ }, "progress": { "upload": "Идет загрузка", - "training": "Идет обучение", - "completed": "Обучение завершено", + "training": "Идет загрузка", + "completed": "Загрузка завершена", "wait": "Это может занять несколько минут", - "tokenLimit": "Превышен лимит токенов, рассмотрите возможность загрузки документа меньшего размера" + "preparing": "Подготовка загрузки", + "tokenLimit": "Превышен лимит токенов, рассмотрите возможность загрузки документа меньшего размера", + "expandDetails": "Развернуть детали загрузки", + "collapseDetails": "Свернуть детали загрузки", + "dismiss": "Закрыть уведомление о загрузке", + "uploadProgress": "Прогресс загрузки {{progress}}%", + "clear": "Очистить" }, "showAdvanced": "Показать расширенные настройки", - "hideAdvanced": "Скрыть расширенные настройки" + "hideAdvanced": "Скрыть расширенные настройки", + "ingestors": { + "local_file": { + "label": "Загрузить файл", + "heading": "Загрузить новый документ" + }, + "crawler": { + "label": "Краулер", + "heading": "Добавить контент с помощью веб-краулера" + }, + "url": { + "label": "Ссылка", + "heading": "Добавить контент из URL" + }, + "github": { + "label": "GitHub", + "heading": "Добавить контент из GitHub" + }, + "reddit": { + "label": "Reddit", + "heading": "Добавить контент из Reddit" + }, + "google_drive": { + "label": "Google Drive", + "heading": "Загрузить из Google Drive" + } + }, + "connectors": { + "auth": { + "connectedUser": "Подключенный пользователь", + "authFailed": "Ошибка аутентификации", + "authUrlFailed": "Не удалось получить URL авторизации", + "popupBlocked": "Не удалось открыть окно аутентификации. Пожалуйста, разрешите всплывающие окна.", + "authCancelled": "Аутентификация отменена", + "connectedAs": "Подключен как {{email}}", + "disconnect": "Отключить" + }, + "googleDrive": { + "connect": "Подключиться к Google Drive", + "sessionExpired": "Сеанс истек. Пожалуйста, переподключитесь к Google Drive.", + "sessionExpiredGeneric": "Сеанс истек. Пожалуйста, переподключите свою учетную запись.", + "validateFailed": "Не удалось проверить сеанс. Пожалуйста, переподключитесь.", + "noSession": "Действительный сеанс не найден. Пожалуйста, переподключитесь к Google Drive.", + "noAccessToken": "Токен доступа недоступен. Пожалуйста, переподключитесь к Google Drive.", + "pickerFailed": "Не удалось открыть средство выбора файлов. Пожалуйста, попробуйте еще раз.", + "selectedFiles": "Выбранные файлы", + "selectFiles": "Выбрать файлы", + "loading": "Загрузка...", + "noFilesSelected": "Файлы или папки не выбраны", + "folders": "Папки", + "files": "Файлы", + "remove": "Удалить", + "folderAlt": "Папка", + "fileAlt": "Файл" + } + } }, "createAPIKey": { "label": "Создать новый API ключ", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index a4247cf1..60a0c91d 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -192,6 +192,9 @@ "uploadDoc": { "label": "上傳新文件", "select": "選擇如何將文件上傳到 DocsGPT", + "selectSource": "選擇新增來源的方式", + "selectedFiles": "已選擇的檔案", + "noFilesSelected": "未選擇檔案", "file": "從檔案", "remote": "遠端", "back": "返回", @@ -220,13 +223,74 @@ }, "progress": { "upload": "正在上傳", - "training": "正在訓練", - "completed": "訓練完成", + "training": "正在上傳", + "completed": "上傳完成", "wait": "這可能需要幾分鐘", - "tokenLimit": "超出令牌限制,請考慮上傳較小的文檔" + "preparing": "準備上傳", + "tokenLimit": "超出令牌限制,請考慮上傳較小的文檔", + "expandDetails": "展開上傳詳情", + "collapseDetails": "摺疊上傳詳情", + "dismiss": "關閉上傳通知", + "uploadProgress": "上傳進度 {{progress}}%", + "clear": "清除" }, "showAdvanced": "顯示進階選項", - "hideAdvanced": "隱藏進階選項" + "hideAdvanced": "隱藏進階選項", + "ingestors": { + "local_file": { + "label": "上傳檔案", + "heading": "上傳新文檔" + }, + "crawler": { + "label": "爬蟲", + "heading": "使用網路爬蟲新增內容" + }, + "url": { + "label": "連結", + "heading": "從URL新增內容" + }, + "github": { + "label": "GitHub", + "heading": "從GitHub新增內容" + }, + "reddit": { + "label": "Reddit", + "heading": "從Reddit新增內容" + }, + "google_drive": { + "label": "Google Drive", + "heading": "從Google Drive上傳" + } + }, + "connectors": { + "auth": { + "connectedUser": "已連接使用者", + "authFailed": "驗證失敗", + "authUrlFailed": "取得授權URL失敗", + "popupBlocked": "無法開啟驗證視窗。請允許彈出視窗。", + "authCancelled": "驗證已取消", + "connectedAs": "已連接為 {{email}}", + "disconnect": "中斷連接" + }, + "googleDrive": { + "connect": "連接到 Google Drive", + "sessionExpired": "工作階段已過期。請重新連接到 Google Drive。", + "sessionExpiredGeneric": "工作階段已過期。請重新連接您的帳戶。", + "validateFailed": "驗證工作階段失敗。請重新連接。", + "noSession": "未找到有效工作階段。請重新連接到 Google Drive。", + "noAccessToken": "存取權杖不可用。請重新連接到 Google Drive。", + "pickerFailed": "無法開啟檔案選擇器。請重試。", + "selectedFiles": "已選擇的檔案", + "selectFiles": "選擇檔案", + "loading": "載入中...", + "noFilesSelected": "未選擇檔案或資料夾", + "folders": "資料夾", + "files": "檔案", + "remove": "移除", + "folderAlt": "資料夾", + "fileAlt": "檔案" + } + } }, "createAPIKey": { "label": "建立新的 API 金鑰", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index b03bd3e7..c3f4ec59 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -192,6 +192,9 @@ "uploadDoc": { "label": "上传新文档", "select": "选择如何将文档上传到 DocsGPT", + "selectSource": "选择添加源的方式", + "selectedFiles": "已选择的文件", + "noFilesSelected": "未选择文件", "file": "从设备上传", "back": "后退", "wait": "请稍等 ...", @@ -220,13 +223,74 @@ }, "progress": { "upload": "正在上传", - "training": "正在训练", - "completed": "训练完成", + "training": "正在上传", + "completed": "上传完成", "wait": "这可能需要几分钟", - "tokenLimit": "超出令牌限制,请考虑上传较小的文档" + "preparing": "准备上传", + "tokenLimit": "超出令牌限制,请考虑上传较小的文档", + "expandDetails": "展开上传详情", + "collapseDetails": "折叠上传详情", + "dismiss": "关闭上传通知", + "uploadProgress": "上传进度 {{progress}}%", + "clear": "清除" }, "showAdvanced": "显示高级选项", - "hideAdvanced": "隐藏高级选项" + "hideAdvanced": "隐藏高级选项", + "ingestors": { + "local_file": { + "label": "上传文件", + "heading": "上传新文档" + }, + "crawler": { + "label": "爬虫", + "heading": "使用网络爬虫添加内容" + }, + "url": { + "label": "链接", + "heading": "从URL添加内容" + }, + "github": { + "label": "GitHub", + "heading": "从GitHub添加内容" + }, + "reddit": { + "label": "Reddit", + "heading": "从Reddit添加内容" + }, + "google_drive": { + "label": "Google Drive", + "heading": "从Google Drive上传" + } + }, + "connectors": { + "auth": { + "connectedUser": "已连接用户", + "authFailed": "身份验证失败", + "authUrlFailed": "获取授权URL失败", + "popupBlocked": "无法打开身份验证窗口。请允许弹出窗口。", + "authCancelled": "身份验证已取消", + "connectedAs": "已连接为 {{email}}", + "disconnect": "断开连接" + }, + "googleDrive": { + "connect": "连接到 Google Drive", + "sessionExpired": "会话已过期。请重新连接到 Google Drive。", + "sessionExpiredGeneric": "会话已过期。请重新连接您的账户。", + "validateFailed": "验证会话失败。请重新连接。", + "noSession": "未找到有效会话。请重新连接到 Google Drive。", + "noAccessToken": "访问令牌不可用。请重新连接到 Google Drive。", + "pickerFailed": "无法打开文件选择器。请重试。", + "selectedFiles": "已选择的文件", + "selectFiles": "选择文件", + "loading": "加载中...", + "noFilesSelected": "未选择文件或文件夹", + "folders": "文件夹", + "files": "文件", + "remove": "删除", + "folderAlt": "文件夹", + "fileAlt": "文件" + } + } }, "createAPIKey": { "label": "创建新的 API 密钥", diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx index 70b9b4f9..6d2223d1 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'; @@ -190,12 +193,12 @@ function Upload({
- Choose Files + {t('modals.uploadDoc.choose')}

- Selected Files + {t('modals.uploadDoc.selectedFiles')}

{files.map((file) => ( @@ -209,7 +212,7 @@ function Upload({ ))} {files.length === 0 && (

- No files selected + {t('modals.uploadDoc.noFilesSelected')}

)}
@@ -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,186 +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)) { - for (const updatedDoc of data) { - if (updatedDoc.id && !docIds.has(updatedDoc.id)) { - dispatch(setSelectedDocs(updatedDoc)); - } - } - } - }); - 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[]) => { @@ -481,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); @@ -489,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', @@ -528,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 = { @@ -541,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, @@ -731,7 +795,7 @@ function Upload({ />

- {option.label} + {t(`modals.uploadDoc.ingestors.${option.value}.label`)}

@@ -739,18 +803,16 @@ function Upload({
); }; - let view; - - if (progress?.type === 'UPLOAD') { - view = ; - } else if (progress?.type === 'TRAINING') { - view = ; - } else { - view = ( + return ( +
{!ingestor.type && (

- Select the way to add your source + {t('modals.uploadDoc.selectSource')}

)} @@ -768,12 +830,12 @@ function Upload({ alt="back" className="h-3 w-3 rotate-180 transform" /> - Back + {t('modals.uploadDoc.back')}

{ingestor.type && - getIngestorSchema(ingestor.type as IngestorType)?.heading} + t(`modals.uploadDoc.ingestors.${ingestor.type}.heading`)}

{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..4fd8e1d2 100644 --- a/frontend/src/upload/uploadSlice.ts +++ b/frontend/src/upload/uploadSlice.ts @@ -10,12 +10,31 @@ 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; + dismissed?: boolean; +} + interface UploadState { attachments: Attachment[]; + tasks: UploadTask[]; } const initialState: UploadState = { attachments: [], + tasks: [], }; export const uploadSlice = createSlice({ @@ -52,6 +71,49 @@ 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) { + const updates = action.payload.updates; + + // When task completes or fails, set dismissed to false to notify user + if (updates.status === 'completed' || updates.status === 'failed') { + state.tasks[index] = { + ...state.tasks[index], + ...updates, + dismissed: false, + }; + } else { + state.tasks[index] = { + ...state.tasks[index], + ...updates, + }; + } + } + }, + dismissUploadTask: (state, action: PayloadAction) => { + const index = state.tasks.findIndex((task) => task.id === action.payload); + if (index !== -1) { + state.tasks[index] = { + ...state.tasks[index], + dismissed: true, + }; + } + }, + removeUploadTask: (state, action: PayloadAction) => { + state.tasks = state.tasks.filter((task) => task.id !== action.payload); + }, }, }); @@ -60,10 +122,15 @@ export const { updateAttachment, removeAttachment, clearAttachments, + addUploadTask, + updateUploadTask, + dismissUploadTask, + removeUploadTask, } = 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;