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>
)}

View File

@@ -207,7 +207,7 @@ export default function SourcesPopup({
<div className="px-4 md:px-6 py-4 opacity-75 hover:opacity-100 transition-opacity duration-200 flex-shrink-0">
<a
href="/settings/documents"
className="text-violets-are-blue text-base font-medium flex items-center gap-2"
className="text-violets-are-blue text-base font-medium inline-flex items-center gap-2"
onClick={onClose}
>
Go to Documents

View File

@@ -217,10 +217,10 @@ export default function ToolsPopup({
</div>
)}
<div className="p-4 flex-shrink-0">
<div className="p-4 flex-shrink-0 opacity-75 hover:opacity-100 transition-opacity duration-200">
<a
href="/settings/tools"
className="text-base text-purple-30 font-medium hover:text-violets-are-blue flex items-center"
className="text-base text-purple-30 font-medium inline-flex items-center"
>
{t('settings.tools.manageTools')}
<img
@@ -233,4 +233,4 @@ export default function ToolsPopup({
</div>
</div>
);
}
}

View File

@@ -57,7 +57,6 @@ const ConversationBubble = forwardRef<
updated?: boolean,
index?: number,
) => void;
attachments?: { fileName: string; id: string }[];
}
>(function ConversationBubble(
{
@@ -72,7 +71,6 @@ const ConversationBubble = forwardRef<
retryBtn,
questionNumber,
handleUpdatedQuestionSubmission,
attachments,
},
ref,
) {
@@ -99,36 +97,6 @@ const ConversationBubble = forwardRef<
handleUpdatedQuestionSubmission?.(editInputBox, true, questionNumber);
};
let bubble;
const renderAttachments = () => {
if (!attachments || attachments.length === 0) return null;
return (
<div className="mt-2 flex flex-wrap gap-2">
{attachments.map((attachment, index) => (
<div
key={index}
className="flex items-center rounded-md bg-gray-100 px-2 py-1 text-sm dark:bg-gray-700"
>
<svg
className="mr-1 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
/>
</svg>
<span>{attachment.fileName}</span>
</div>
))}
</div>
);
};
if (type === 'QUESTION') {
bubble = (
<div
@@ -157,7 +125,6 @@ const ConversationBubble = forwardRef<
>
{message}
</div>
{renderAttachments()}
</div>
<button
onClick={() => {

View File

@@ -171,7 +171,6 @@ export default function ConversationMessages({
handleUpdatedQuestionSubmission={handleQuestionSubmission}
questionNumber={index}
sources={query.sources}
attachments={query.attachments}
/>
{prepResponseView(query, index)}
</Fragment>

View File

@@ -9,11 +9,21 @@ export interface Message {
type: MESSAGE_TYPE;
}
export interface Attachment {
id?: string;
fileName: string;
status: 'uploading' | 'processing' | 'completed' | 'failed';
progress: number;
taskId?: string;
token_count?: number;
}
export interface ConversationState {
queries: Query[];
status: Status;
conversationId: string | null;
attachments?: { fileName: string; id: string }[];
attachments: Attachment[];
}
export interface Answer {

View File

@@ -7,7 +7,13 @@ import {
handleFetchAnswer,
handleFetchAnswerSteaming,
} from './conversationHandlers';
import { Answer, ConversationState, Query, Status } from './conversationModels';
import {
Answer,
Query,
Status,
ConversationState,
Attachment,
} from './conversationModels';
const initialState: ConversationState = {
queries: [],
@@ -38,7 +44,9 @@ export const fetchAnswer = createAsyncThunk<
let isSourceUpdated = false;
const state = getState() as RootState;
const attachments = state.conversation.attachments?.map((a) => a.id) || [];
const attachmentIds = state.conversation.attachments
.filter((a) => a.id && a.status === 'completed')
.map((a) => a.id) as string[];
const currentConversationId = state.conversation.conversationId;
const conversationIdToSend = isPreview ? null : currentConversationId;
const save_conversation = isPreview ? false : true;
@@ -129,7 +137,7 @@ export const fetchAnswer = createAsyncThunk<
},
indx,
state.preference.selectedAgent?.id,
attachments,
attachmentIds,
save_conversation,
);
} else {
@@ -144,7 +152,7 @@ export const fetchAnswer = createAsyncThunk<
state.preference.chunks,
state.preference.token_limit,
state.preference.selectedAgent?.id,
attachments,
attachmentIds,
save_conversation,
);
if (answer) {
@@ -301,12 +309,34 @@ export const conversationSlice = createSlice({
const { index, message } = action.payload;
state.queries[index].error = message;
},
setAttachments: (
state,
action: PayloadAction<{ fileName: string; id: string }[]>,
) => {
setAttachments: (state, action: PayloadAction<Attachment[]>) => {
state.attachments = action.payload;
},
addAttachment: (state, action: PayloadAction<Attachment>) => {
state.attachments.push(action.payload);
},
updateAttachment: (
state,
action: PayloadAction<{
taskId: string;
updates: Partial<Attachment>;
}>,
) => {
const index = state.attachments.findIndex(
(att) => att.taskId === action.payload.taskId,
);
if (index !== -1) {
state.attachments[index] = {
...state.attachments[index],
...action.payload.updates,
};
}
},
removeAttachment: (state, action: PayloadAction<string>) => {
state.attachments = state.attachments.filter(
(att) => att.taskId !== action.payload && att.id !== action.payload,
);
},
resetConversation: (state) => {
state.queries = initialState.queries;
state.status = initialState.status;
@@ -337,6 +367,11 @@ export const selectQueries = (state: RootState) => state.conversation.queries;
export const selectStatus = (state: RootState) => state.conversation.status;
export const selectAttachments = (state: RootState) =>
state.conversation.attachments;
export const selectCompletedAttachments = (state: RootState) =>
state.conversation.attachments.filter((att) => att.status === 'completed');
export const {
addQuery,
updateQuery,
@@ -348,6 +383,9 @@ export const {
updateToolCalls,
setConversation,
setAttachments,
addAttachment,
updateAttachment,
removeAttachment,
resetConversation,
} = conversationSlice.actions;
export default conversationSlice.reducer;

View File

@@ -0,0 +1,10 @@
export function getOS() {
const userAgent = window.navigator.userAgent;
if (userAgent.indexOf('Mac') !== -1) return 'mac';
if (userAgent.indexOf('Win') !== -1) return 'win';
return 'linux';
}
export function isTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
}