diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 0d574f89..1356f5fb 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -32,6 +32,7 @@ const endpoints = { DELETE_CHUNK: (docId: string, chunkId: string) => `/api/delete_chunk?id=${docId}&chunk_id=${chunkId}`, UPDATE_CHUNK: '/api/update_chunk', + STORE_ATTACHMENT: '/api/store_attachment', }, CONVERSATION: { ANSWER: '/api/answer', diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index 46106d85..c781dd48 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDarkTheme } from '../hooks'; import { useSelector } from 'react-redux'; +import userService from '../api/services/userService'; +import endpoints from '../api/endpoints'; import PaperPlane from '../assets/paper_plane.svg'; import SourceIcon from '../assets/source.svg'; import ToolIcon from '../assets/tool.svg'; @@ -9,11 +11,12 @@ import SpinnerDark from '../assets/spinner-dark.svg'; import Spinner from '../assets/spinner.svg'; import SourcesPopup from './SourcesPopup'; import ToolsPopup from './ToolsPopup'; -import { selectSelectedDocs } from '../preferences/preferenceSlice'; +import { selectSelectedDocs, selectToken } from '../preferences/preferenceSlice'; import { ActiveState } from '../models/misc'; import Upload from '../upload/Upload'; import ClipIcon from '../assets/clip.svg'; + interface MessageInputProps { value: string; onChange: (e: React.ChangeEvent) => void; @@ -21,6 +24,15 @@ interface MessageInputProps { loading: boolean; } +interface UploadState { + taskId: string; + fileName: string; + progress: number; + attachment_id?: string; + token_count?: number; + status: 'uploading' | 'processing' | 'completed' | 'failed'; +} + export default function MessageInput({ value, onChange, @@ -34,10 +46,139 @@ export default function MessageInput({ const toolButtonRef = useRef(null); const [isSourcesPopupOpen, setIsSourcesPopupOpen] = useState(false); const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false); - const [uploadModalState, setUploadModalState] = - useState('INACTIVE'); + const [uploadModalState, setUploadModalState] = useState('INACTIVE'); + const [uploads, setUploads] = useState([]); const selectedDocs = useSelector(selectSelectedDocs); + const token = useSelector(selectToken); + + const handleFileAttachment = (e: React.ChangeEvent) => { + if (!e.target.files || e.target.files.length === 0) return; + + const file = e.target.files[0]; + const formData = new FormData(); + formData.append('file', file); + + const apiHost = import.meta.env.VITE_API_HOST; + const xhr = new XMLHttpRequest(); + + const uploadState: UploadState = { + taskId: '', + fileName: file.name, + progress: 0, + status: 'uploading' + }; + + setUploads(prev => [...prev, uploadState]); + const uploadIndex = uploads.length; + + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + const progress = Math.round((event.loaded / event.total) * 100); + setUploads(prev => prev.map((upload, index) => + index === uploadIndex + ? { ...upload, progress } + : upload + )); + } + }); + + xhr.onload = () => { + if (xhr.status === 200) { + const response = JSON.parse(xhr.responseText); + console.log('File uploaded successfully:', response); + + if (response.task_id) { + setUploads(prev => prev.map((upload, index) => + index === uploadIndex + ? { ...upload, taskId: response.task_id, status: 'processing' } + : upload + )); + } + } else { + setUploads(prev => prev.map((upload, index) => + index === uploadIndex + ? { ...upload, status: 'failed' } + : upload + )); + console.error('Error uploading file:', xhr.responseText); + } + }; + + xhr.onerror = () => { + setUploads(prev => prev.map((upload, index) => + index === uploadIndex + ? { ...upload, status: 'failed' } + : upload + )); + console.error('Network error during file upload'); + }; + + xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`); + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.send(formData); + e.target.value = ''; + }; + + useEffect(() => { + let timeoutIds: number[] = []; + + const checkTaskStatus = () => { + const processingUploads = uploads.filter(upload => + upload.status === 'processing' && upload.taskId + ); + + processingUploads.forEach(upload => { + userService + .getTaskStatus(upload.taskId, null) + .then((data) => data.json()) + .then((data) => { + console.log('Task status:', data); + + setUploads(prev => prev.map(u => { + if (u.taskId !== upload.taskId) return u; + + if (data.status === 'SUCCESS') { + return { + ...u, + status: 'completed', + progress: 100, + attachment_id: data.result?.attachment_id, + token_count: data.result?.token_count + }; + } else if (data.status === 'FAILURE') { + return { ...u, status: 'failed' }; + } else if (data.status === 'PROGRESS' && data.result?.current) { + return { ...u, progress: data.result.current }; + } + return u; + })); + + if (data.status !== 'SUCCESS' && data.status !== 'FAILURE') { + const timeoutId = window.setTimeout(() => checkTaskStatus(), 2000); + timeoutIds.push(timeoutId); + } + }) + .catch((error) => { + console.error('Error checking task status:', error); + setUploads(prev => prev.map(u => + u.taskId === upload.taskId + ? { ...u, status: 'failed' } + : u + )); + }); + }); + }; + + if (uploads.some(upload => upload.status === 'processing')) { + const timeoutId = window.setTimeout(checkTaskStatus, 2000); + timeoutIds.push(timeoutId); + } + + return () => { + timeoutIds.forEach(id => clearTimeout(id)); + }; + }, [uploads]); const handleInput = () => { if (inputRef.current) { @@ -70,9 +211,68 @@ export default function MessageInput({ console.log('Selected document:', doc); }; + const renderUploadStatus = () => { + const activeUploads = uploads.filter(u => + u.status === 'uploading' || u.status === 'processing' + ); + + if (activeUploads.length === 0) { + return 'Attach'; + } + + return `Uploading ${activeUploads.length} file(s)`; + }; + return (
+
+ {uploads.map((upload, index) => ( +
+ {upload.fileName} + + {upload.status === 'completed' && ( + + )} + + {upload.status === 'failed' && ( + + )} + + {(upload.status === 'uploading' || upload.status === 'processing') && ( +
+ + + + +
+ )} +
+ ))} +
+
{/* Additional badges can be added here in the future */}