Merge branch 'main' into feat/agent-menu

This commit is contained in:
Siddhant Rai
2025-04-16 18:35:38 +05:30
21 changed files with 1568 additions and 231 deletions

View File

@@ -4,13 +4,20 @@ import { useDispatch, useSelector } from 'react-redux';
import endpoints from '../api/endpoints';
import userService from '../api/services/userService';
import AlertIcon from '../assets/alert.svg';
import ClipIcon from '../assets/clip.svg';
import ExitIcon from '../assets/exit.svg';
import PaperPlane from '../assets/paper_plane.svg';
import SourceIcon from '../assets/source.svg';
import SpinnerDark from '../assets/spinner-dark.svg';
import Spinner from '../assets/spinner.svg';
import ToolIcon from '../assets/tool.svg';
import { setAttachments } from '../conversation/conversationSlice';
import {
addAttachment,
removeAttachment,
selectAttachments,
updateAttachment,
} from '../conversation/conversationSlice';
import { useDarkTheme } from '../hooks';
import { ActiveState } from '../models/misc';
import {
@@ -18,6 +25,7 @@ import {
selectToken,
} from '../preferences/preferenceSlice';
import Upload from '../upload/Upload';
import { getOS, isTouchDevice } from '../utils/browserUtils';
import SourcesPopup from './SourcesPopup';
import ToolsPopup from './ToolsPopup';
@@ -56,13 +64,35 @@ export default function MessageInput({
const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false);
const [uploadModalState, setUploadModalState] =
useState<ActiveState>('INACTIVE');
const [uploads, setUploads] = useState<UploadState[]>([]);
const selectedDocs = useSelector(selectSelectedDocs);
const token = useSelector(selectToken);
const attachments = useSelector(selectAttachments);
const dispatch = useDispatch();
const browserOS = getOS();
const isTouch = isTouchDevice();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
((browserOS === 'win' || browserOS === 'linux') &&
event.ctrlKey &&
event.key === 'k') ||
(browserOS === 'mac' && event.metaKey && event.key === 'k')
) {
event.preventDefault();
setIsSourcesPopupOpen(!isSourcesPopupOpen);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [browserOS]);
const handleFileAttachment = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || e.target.files.length === 0) return;
@@ -73,23 +103,23 @@ export default function MessageInput({
const apiHost = import.meta.env.VITE_API_HOST;
const xhr = new XMLHttpRequest();
const uploadState: UploadState = {
taskId: '',
const newAttachment = {
fileName: file.name,
progress: 0,
status: 'uploading',
status: 'uploading' as const,
taskId: '',
};
setUploads((prev) => [...prev, uploadState]);
const uploadIndex = uploads.length;
dispatch(addAttachment(newAttachment));
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,
),
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: { progress },
}),
);
}
});
@@ -97,34 +127,35 @@ export default function MessageInput({
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,
),
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: {
taskId: response.task_id,
status: 'processing',
progress: 10,
},
}),
);
}
} else {
setUploads((prev) =>
prev.map((upload, index) =>
index === uploadIndex ? { ...upload, status: 'failed' } : upload,
),
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: { status: 'failed' },
}),
);
console.error('Error uploading file:', xhr.responseText);
}
};
xhr.onerror = () => {
setUploads((prev) =>
prev.map((upload, index) =>
index === uploadIndex ? { ...upload, status: 'failed' } : upload,
),
dispatch(
updateAttachment({
taskId: newAttachment.taskId,
updates: { status: 'failed' },
}),
);
console.error('Network error during file upload');
};
xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`);
@@ -134,69 +165,63 @@ export default function MessageInput({
};
useEffect(() => {
let timeoutIds: number[] = [];
const checkTaskStatus = () => {
const processingUploads = uploads.filter(
(upload) => upload.status === 'processing' && upload.taskId,
const processingAttachments = attachments.filter(
(att) => att.status === 'processing' && att.taskId,
);
processingUploads.forEach((upload) => {
processingAttachments.forEach((attachment) => {
userService
.getTaskStatus(upload.taskId, null)
.getTaskStatus(attachment.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,
if (data.status === 'SUCCESS') {
dispatch(
updateAttachment({
taskId: attachment.taskId!,
updates: {
status: 'completed',
progress: 100,
attachment_id: data.result?.attachment_id,
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,
},
}),
);
} else if (data.status === 'FAILURE') {
dispatch(
updateAttachment({
taskId: attachment.taskId!,
updates: { status: 'failed' },
}),
);
} else if (data.status === 'PROGRESS' && data.result?.current) {
dispatch(
updateAttachment({
taskId: attachment.taskId!,
updates: { progress: data.result.current },
}),
);
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,
),
.catch(() => {
dispatch(
updateAttachment({
taskId: attachment.taskId!,
updates: { status: 'failed' },
}),
);
});
});
};
if (uploads.some((upload) => upload.status === 'processing')) {
const timeoutId = window.setTimeout(checkTaskStatus, 2000);
timeoutIds.push(timeoutId);
}
const interval = setInterval(() => {
if (attachments.some((att) => att.status === 'processing')) {
checkTaskStatus();
}
}, 2000);
return () => {
timeoutIds.forEach((id) => clearTimeout(id));
};
}, [uploads]);
return () => clearInterval(interval);
}, [attachments, dispatch]);
const handleInput = () => {
if (inputRef.current) {
@@ -230,42 +255,56 @@ export default function MessageInput({
};
const handleSubmit = () => {
const completedAttachments = uploads
.filter((upload) => upload.status === 'completed' && upload.attachment_id)
.map((upload) => ({
fileName: upload.fileName,
id: upload.attachment_id as string,
}));
dispatch(setAttachments(completedAttachments));
onSubmit();
};
return (
<div className="mx-2 flex w-full flex-col">
<div className="relative flex w-full flex-col rounded-[23px] border border-dark-gray bg-lotion dark:border-grey dark:bg-transparent">
<div className="flex flex-wrap gap-1.5 px-4 pb-0 pt-3 sm:gap-2 sm:px-6">
{uploads.map((upload, index) => (
{attachments.map((attachment, index) => (
<div
key={index}
className="flex items-center rounded-[32px] border border-[#AAAAAA] bg-white px-2 py-1 text-[12px] text-[#5D5D5D] dark:border-purple-taupe dark:bg-[#1F2028] dark:text-bright-gray sm:px-3 sm:py-1.5 sm:text-[14px]"
className={`group relative flex items-center rounded-[32px] border border-[#AAAAAA] bg-white px-2 py-1 text-[12px] text-[#5D5D5D] dark:border-purple-taupe dark:bg-[#1F2028] dark:text-bright-gray sm:px-3 sm:py-1.5 sm:text-[14px] ${
attachment.status !== 'completed' ? 'opacity-70' : 'opacity-100'
}`}
title={attachment.fileName}
>
<span className="max-w-[120px] truncate font-medium sm:max-w-[150px]">
{upload.fileName}
{attachment.fileName}
</span>
{upload.status === 'completed' && (
<span className="ml-2 text-green-500"></span>
{attachment.status === 'completed' && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-white p-1 opacity-0 transition-opacity hover:bg-white/95 focus:opacity-100 group-hover:opacity-100 dark:bg-[#1F2028] dark:hover:bg-[#1F2028]/95"
onClick={() => {
if (attachment.id) {
dispatch(removeAttachment(attachment.id));
}
}}
aria-label="Remove attachment"
>
<img
src={ExitIcon}
alt="Remove"
className="h-2.5 w-2.5 filter dark:invert"
/>
</button>
)}
{upload.status === 'failed' && (
<span className="ml-2 text-red-500"></span>
{attachment.status === 'failed' && (
<img
src={AlertIcon}
alt="Upload failed"
className="ml-2 h-3.5 w-3.5"
title="Upload failed"
/>
)}
{(upload.status === 'uploading' ||
upload.status === 'processing') && (
{(attachment.status === 'uploading' ||
attachment.status === 'processing') && (
<div className="relative ml-2 h-4 w-4">
<svg className="h-4 w-4" viewBox="0 0 24 24">
{/* Background circle */}
<circle
className="text-gray-200 dark:text-gray-700"
cx="12"
@@ -284,7 +323,7 @@ export default function MessageInput({
strokeWidth="4"
fill="none"
strokeDasharray="62.83"
strokeDashoffset={62.83 - (upload.progress / 100) * 62.83}
strokeDashoffset={62.83 * (1 - attachment.progress / 100)}
transform="rotate(-90 12 12)"
/>
</svg>
@@ -317,19 +356,29 @@ export default function MessageInput({
{showSourceButton && (
<button
ref={sourceButtonRef}
className="xs:px-3 xs:py-1.5 xs:max-w-[150px] flex max-w-[130px] items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 dark:border-purple-taupe dark:hover:bg-[#2C2E3C]"
className="xs:px-3 xs:py-1.5 flex max-w-[130px] items-center rounded-[32px] border border-[#AAAAAA] px-2 py-1 transition-colors hover:bg-gray-100 dark:border-purple-taupe dark:hover:bg-[#2C2E3C] sm:max-w-[150px]"
onClick={() => setIsSourcesPopupOpen(!isSourcesPopupOpen)}
title={
selectedDocs
? selectedDocs.name
: t('conversation.sources.title')
}
>
<img
src={SourceIcon}
alt="Sources"
className="mr-1 h-3.5 w-3.5 flex-shrink-0 sm:mr-1.5 sm:h-4 sm:w-4"
className="mr-1 h-3.5 w-3.5 flex-shrink-0 sm:mr-1.5 sm:h-4"
/>
<span className="xs:text-[12px] overflow-hidden truncate text-[10px] font-medium text-[#5D5D5D] dark:text-bright-gray sm:text-[14px]">
{selectedDocs
? selectedDocs.name
: t('conversation.sources.title')}
</span>
{!isTouch && (
<span className="ml-1 hidden text-[10px] text-gray-500 dark:text-gray-400 sm:inline-block">
{browserOS === 'mac' ? '(⌘K)' : '(ctrl+K)'}
</span>
)}
</button>
)}