feat: Enhance agent selection and conversation handling

- Added functionality to select agents in the Navigation component, allowing users to reset conversations and set the selected agent.
- Updated the MessageInput component to conditionally show source and tool buttons based on the selected agent.
- Modified the Conversation component to handle agent-specific queries and manage file uploads.
- Improved conversation fetching logic to include agent IDs and handle attachments.
- Introduced new types for conversation summaries and results to streamline API responses.
- Refactored Redux slices to manage selected agent state and improve overall state management.
- Enhanced error handling and loading states across components for better user experience.
This commit is contained in:
Siddhant Rai
2025-04-15 11:53:53 +05:30
parent fa1f9d7009
commit 7c69e99914
16 changed files with 445 additions and 237 deletions

View File

@@ -1,43 +1,51 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDarkTheme } from '../hooks';
import { useSelector, useDispatch } from 'react-redux';
import userService from '../api/services/userService';
import { useDispatch, useSelector } from 'react-redux';
import endpoints from '../api/endpoints';
import userService from '../api/services/userService';
import ClipIcon from '../assets/clip.svg';
import PaperPlane from '../assets/paper_plane.svg';
import SourceIcon from '../assets/source.svg';
import ToolIcon from '../assets/tool.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 { useDarkTheme } from '../hooks';
import { ActiveState } from '../models/misc';
import {
selectSelectedDocs,
selectToken,
} from '../preferences/preferenceSlice';
import Upload from '../upload/Upload';
import SourcesPopup from './SourcesPopup';
import ToolsPopup from './ToolsPopup';
import { selectSelectedDocs, selectToken } from '../preferences/preferenceSlice';
import { ActiveState } from '../models/misc';
import Upload from '../upload/Upload';
import ClipIcon from '../assets/clip.svg';
import { setAttachments } from '../conversation/conversationSlice';
interface MessageInputProps {
type MessageInputProps = {
value: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onSubmit: () => void;
loading: boolean;
}
showSourceButton?: boolean;
showToolButton?: boolean;
};
interface UploadState {
type UploadState = {
taskId: string;
fileName: string;
progress: number;
attachment_id?: string;
token_count?: number;
status: 'uploading' | 'processing' | 'completed' | 'failed';
}
};
export default function MessageInput({
value,
onChange,
onSubmit,
loading,
showSourceButton = true,
showToolButton = true,
}: MessageInputProps) {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
@@ -46,12 +54,13 @@ export default function MessageInput({
const toolButtonRef = useRef<HTMLButtonElement>(null);
const [isSourcesPopupOpen, setIsSourcesPopupOpen] = useState(false);
const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false);
const [uploadModalState, setUploadModalState] = useState<ActiveState>('INACTIVE');
const [uploadModalState, setUploadModalState] =
useState<ActiveState>('INACTIVE');
const [uploads, setUploads] = useState<UploadState[]>([]);
const selectedDocs = useSelector(selectSelectedDocs);
const token = useSelector(selectToken);
const dispatch = useDispatch();
const handleFileAttachment = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -68,20 +77,20 @@ export default function MessageInput({
taskId: '',
fileName: file.name,
progress: 0,
status: 'uploading'
status: 'uploading',
};
setUploads(prev => [...prev, uploadState]);
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
));
setUploads((prev) =>
prev.map((upload, index) =>
index === uploadIndex ? { ...upload, progress } : upload,
),
);
}
});
@@ -91,28 +100,30 @@ export default function MessageInput({
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
));
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
));
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
));
setUploads((prev) =>
prev.map((upload, index) =>
index === uploadIndex ? { ...upload, status: 'failed' } : upload,
),
);
console.error('Network error during file upload');
};
@@ -126,59 +137,64 @@ export default function MessageInput({
let timeoutIds: number[] = [];
const checkTaskStatus = () => {
const processingUploads = uploads.filter(upload =>
upload.status === 'processing' && upload.taskId
const processingUploads = uploads.filter(
(upload) => upload.status === 'processing' && upload.taskId,
);
processingUploads.forEach(upload => {
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;
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') {
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);
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
));
setUploads((prev) =>
prev.map((u) =>
u.taskId === upload.taskId ? { ...u, status: 'failed' } : u,
),
);
});
});
};
if (uploads.some(upload => upload.status === 'processing')) {
if (uploads.some((upload) => upload.status === 'processing')) {
const timeoutId = window.setTimeout(checkTaskStatus, 2000);
timeoutIds.push(timeoutId);
}
return () => {
timeoutIds.forEach(id => clearTimeout(id));
timeoutIds.forEach((id) => clearTimeout(id));
};
}, [uploads]);
@@ -213,29 +229,30 @@ export default function MessageInput({
console.log('Selected document:', doc);
};
const handleSubmit = () => {
const completedAttachments = uploads
.filter(upload => upload.status === 'completed' && upload.attachment_id)
.map(upload => ({
.filter((upload) => upload.status === 'completed' && upload.attachment_id)
.map((upload) => ({
fileName: upload.fileName,
id: upload.attachment_id as string
id: upload.attachment_id as string,
}));
dispatch(setAttachments(completedAttachments));
onSubmit();
};
return (
<div className="flex flex-col w-full mx-2">
<div className="flex flex-col w-full rounded-[23px] border dark:border-grey border-dark-gray bg-lotion dark:bg-transparent relative">
<div className="flex flex-wrap gap-1.5 sm:gap-2 px-4 sm:px-6 pt-3 pb-0">
<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) => (
<div
key={index}
className="flex items-center px-2 sm:px-3 py-1 sm:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe bg-white dark:bg-[#1F2028] text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray"
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]"
>
<span className="font-medium truncate max-w-[120px] sm:max-w-[150px]">{upload.fileName}</span>
<span className="max-w-[120px] truncate font-medium sm:max-w-[150px]">
{upload.fileName}
</span>
{upload.status === 'completed' && (
<span className="ml-2 text-green-500"></span>
@@ -245,9 +262,10 @@ export default function MessageInput({
<span className="ml-2 text-red-500"></span>
)}
{(upload.status === 'uploading' || upload.status === 'processing') && (
<div className="ml-2 w-4 h-4 relative">
<svg className="w-4 h-4" viewBox="0 0 24 24">
{(upload.status === 'uploading' ||
upload.status === 'processing') && (
<div className="relative ml-2 h-4 w-4">
<svg className="h-4 w-4" viewBox="0 0 24 24">
<circle
className="text-gray-200 dark:text-gray-700"
cx="12"
@@ -287,41 +305,57 @@ export default function MessageInput({
onChange={onChange}
tabIndex={1}
placeholder={t('inputPlaceholder')}
className="inputbox-style w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap rounded-t-[23px] bg-lotion dark:bg-transparent py-3 sm:py-5 text-base leading-tight opacity-100 focus:outline-none dark:text-bright-gray dark:placeholder-bright-gray dark:placeholder-opacity-50 px-4 sm:px-6 no-scrollbar"
className="inputbox-style no-scrollbar w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap rounded-t-[23px] bg-lotion px-4 py-3 text-base leading-tight opacity-100 focus:outline-none dark:bg-transparent dark:text-bright-gray dark:placeholder-bright-gray dark:placeholder-opacity-50 sm:px-6 sm:py-5"
onInput={handleInput}
onKeyDown={handleKeyDown}
aria-label={t('inputPlaceholder')}
/>
</div>
<div className="flex items-center px-3 sm:px-4 py-1.5 sm:py-2">
<div className="flex-grow flex flex-wrap gap-1 sm:gap-2">
<button
ref={sourceButtonRef}
className="flex items-center px-2 xs:px-3 py-1 xs:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe hover:bg-gray-100 dark:hover:bg-[#2C2E3C] transition-colors max-w-[130px] xs:max-w-[150px]"
onClick={() => setIsSourcesPopupOpen(!isSourcesPopupOpen)}
>
<img src={SourceIcon} alt="Sources" className="w-3.5 sm:w-4 h-3.5 sm:h-4 mr-1 sm:mr-1.5 flex-shrink-0" />
<span className="text-[10px] xs:text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray font-medium truncate overflow-hidden">
{selectedDocs
? selectedDocs.name
: t('conversation.sources.title')}
</span>
</button>
<div className="flex items-center px-3 py-1.5 sm:px-4 sm:py-2">
<div className="flex flex-grow flex-wrap gap-1 sm:gap-2">
{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]"
onClick={() => setIsSourcesPopupOpen(!isSourcesPopupOpen)}
>
<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"
/>
<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>
</button>
)}
<button
ref={toolButtonRef}
className="flex items-center px-2 xs:px-3 py-1 xs:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe hover:bg-gray-100 dark:hover:bg-[#2C2E3C] transition-colors max-w-[130px] xs:max-w-[150px]"
onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)}
>
<img src={ToolIcon} alt="Tools" className="w-3.5 sm:w-4 h-3.5 sm:h-4 mr-1 sm:mr-1.5 flex-shrink-0" />
<span className="text-[10px] xs:text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray font-medium truncate overflow-hidden">
{t('settings.tools.label')}
</span>
</button>
<label className="flex items-center px-2 xs:px-3 py-1 xs:py-1.5 rounded-[32px] border border-[#AAAAAA] dark:border-purple-taupe hover:bg-gray-100 dark:hover:bg-[#2C2E3C] transition-colors cursor-pointer">
<img src={ClipIcon} alt="Attach" className="w-3.5 sm:w-4 h-3.5 sm:h-4 mr-1 sm:mr-1.5" />
<span className="text-[10px] xs:text-[12px] sm:text-[14px] text-[#5D5D5D] dark:text-bright-gray font-medium">
{showToolButton && (
<button
ref={toolButtonRef}
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]"
onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)}
>
<img
src={ToolIcon}
alt="Tools"
className="mr-1 h-3.5 w-3.5 flex-shrink-0 sm:mr-1.5 sm:h-4 sm:w-4"
/>
<span className="xs:text-[12px] overflow-hidden truncate text-[10px] font-medium text-[#5D5D5D] dark:text-bright-gray sm:text-[14px]">
{t('settings.tools.label')}
</span>
</button>
)}
<label className="xs:px-3 xs:py-1.5 flex cursor-pointer 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]">
<img
src={ClipIcon}
alt="Attach"
className="mr-1 h-3.5 w-3.5 sm:mr-1.5 sm:h-4 sm:w-4"
/>
<span className="xs:text-[12px] text-[10px] font-medium text-[#5D5D5D] dark:text-bright-gray sm:text-[14px]">
Attach
</span>
<input
@@ -337,18 +371,18 @@ export default function MessageInput({
<button
onClick={loading ? undefined : handleSubmit}
aria-label={loading ? t('loading') : t('send')}
className={`flex items-center justify-center p-2 sm:p-2.5 rounded-full ${loading ? 'bg-gray-300 dark:bg-gray-600' : 'bg-black dark:bg-white'} ml-auto flex-shrink-0`}
className={`flex items-center justify-center rounded-full p-2 sm:p-2.5 ${loading ? 'bg-gray-300 dark:bg-gray-600' : 'bg-black dark:bg-white'} ml-auto flex-shrink-0`}
disabled={loading}
>
{loading ? (
<img
src={isDarkTheme ? SpinnerDark : Spinner}
className="w-3.5 sm:w-4 h-3.5 sm:h-4 animate-spin"
className="h-3.5 w-3.5 animate-spin sm:h-4 sm:w-4"
alt={t('loading')}
/>
) : (
<img
className={`w-3.5 sm:w-4 h-3.5 sm:h-4 ${isDarkTheme ? 'filter invert' : ''}`}
className={`h-3.5 w-3.5 sm:h-4 sm:w-4 ${isDarkTheme ? 'invert filter' : ''}`}
src={PaperPlane}
alt={t('send')}
/>