2024-10-24 16:35:51 -04:00
|
|
|
'use client';
|
2024-10-11 18:00:22 +05:30
|
|
|
|
2025-03-20 14:10:45 -07:00
|
|
|
import type { Attachment, Message, UIMessage } from 'ai';
|
2024-10-30 16:01:24 +05:30
|
|
|
import cx from 'classnames';
|
2024-11-15 12:18:17 -05:00
|
|
|
import type React from 'react';
|
|
|
|
|
import {
|
2024-10-11 18:00:22 +05:30
|
|
|
useRef,
|
|
|
|
|
useEffect,
|
|
|
|
|
useState,
|
|
|
|
|
useCallback,
|
2024-11-15 12:18:17 -05:00
|
|
|
type Dispatch,
|
|
|
|
|
type SetStateAction,
|
|
|
|
|
type ChangeEvent,
|
2024-12-03 17:49:38 +03:00
|
|
|
memo,
|
2024-10-24 16:35:51 -04:00
|
|
|
} from 'react';
|
|
|
|
|
import { toast } from 'sonner';
|
2024-11-01 15:31:54 +05:30
|
|
|
import { useLocalStorage, useWindowSize } from 'usehooks-ts';
|
2024-10-11 18:00:22 +05:30
|
|
|
|
2024-12-20 23:07:23 +05:30
|
|
|
import { ArrowUpIcon, PaperclipIcon, StopIcon } from './icons';
|
2024-10-24 16:35:51 -04:00
|
|
|
import { PreviewAttachment } from './preview-attachment';
|
2024-11-15 10:14:25 -05:00
|
|
|
import { Button } from './ui/button';
|
|
|
|
|
import { Textarea } from './ui/textarea';
|
2024-12-03 17:49:38 +03:00
|
|
|
import { SuggestedActions } from './suggested-actions';
|
2024-12-04 17:27:48 +03:00
|
|
|
import equal from 'fast-deep-equal';
|
2025-03-20 14:10:45 -07:00
|
|
|
import { UseChatHelpers } from '@ai-sdk/react';
|
2024-10-11 18:00:22 +05:30
|
|
|
|
2024-12-03 17:49:38 +03:00
|
|
|
function PureMultimodalInput({
|
2024-11-05 17:15:51 +03:00
|
|
|
chatId,
|
2024-10-11 18:00:22 +05:30
|
|
|
input,
|
|
|
|
|
setInput,
|
2025-03-11 15:33:18 -07:00
|
|
|
status,
|
2024-10-11 18:00:22 +05:30
|
|
|
stop,
|
|
|
|
|
attachments,
|
|
|
|
|
setAttachments,
|
|
|
|
|
messages,
|
2024-10-30 16:01:24 +05:30
|
|
|
setMessages,
|
2024-10-11 18:00:22 +05:30
|
|
|
append,
|
|
|
|
|
handleSubmit,
|
2024-10-30 16:01:24 +05:30
|
|
|
className,
|
2024-10-11 18:00:22 +05:30
|
|
|
}: {
|
2024-11-05 17:15:51 +03:00
|
|
|
chatId: string;
|
2025-03-11 15:33:18 -07:00
|
|
|
input: UseChatHelpers['input'];
|
|
|
|
|
setInput: UseChatHelpers['setInput'];
|
|
|
|
|
status: UseChatHelpers['status'];
|
2024-10-11 18:00:22 +05:30
|
|
|
stop: () => void;
|
|
|
|
|
attachments: Array<Attachment>;
|
|
|
|
|
setAttachments: Dispatch<SetStateAction<Array<Attachment>>>;
|
2025-03-20 14:10:45 -07:00
|
|
|
messages: Array<UIMessage>;
|
|
|
|
|
setMessages: UseChatHelpers['setMessages'];
|
2025-03-11 15:33:18 -07:00
|
|
|
append: UseChatHelpers['append'];
|
|
|
|
|
handleSubmit: UseChatHelpers['handleSubmit'];
|
2024-10-30 16:01:24 +05:30
|
|
|
className?: string;
|
2024-10-11 18:00:22 +05:30
|
|
|
}) {
|
|
|
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
2024-10-14 23:01:46 +05:30
|
|
|
const { width } = useWindowSize();
|
2024-10-11 18:00:22 +05:30
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (textareaRef.current) {
|
|
|
|
|
adjustHeight();
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const adjustHeight = () => {
|
|
|
|
|
if (textareaRef.current) {
|
2024-10-24 16:35:51 -04:00
|
|
|
textareaRef.current.style.height = 'auto';
|
2024-10-11 18:00:22 +05:30
|
|
|
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-01-08 17:00:38 +05:30
|
|
|
const resetHeight = () => {
|
|
|
|
|
if (textareaRef.current) {
|
|
|
|
|
textareaRef.current.style.height = 'auto';
|
|
|
|
|
textareaRef.current.style.height = '98px';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2024-10-31 21:02:05 +05:30
|
|
|
const [localStorageInput, setLocalStorageInput] = useLocalStorage(
|
|
|
|
|
'input',
|
2024-11-15 13:00:15 -05:00
|
|
|
'',
|
2024-10-31 21:02:05 +05:30
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (textareaRef.current) {
|
|
|
|
|
const domValue = textareaRef.current.value;
|
|
|
|
|
// Prefer DOM value over localStorage to handle hydration
|
|
|
|
|
const finalValue = domValue || localStorageInput || '';
|
|
|
|
|
setInput(finalValue);
|
|
|
|
|
adjustHeight();
|
|
|
|
|
}
|
|
|
|
|
// Only run once after hydration
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setLocalStorageInput(input);
|
|
|
|
|
}, [input, setLocalStorageInput]);
|
|
|
|
|
|
2024-10-11 18:00:22 +05:30
|
|
|
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
|
|
|
setInput(event.target.value);
|
|
|
|
|
adjustHeight();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
const [uploadQueue, setUploadQueue] = useState<Array<string>>([]);
|
|
|
|
|
|
|
|
|
|
const submitForm = useCallback(() => {
|
2024-11-05 17:15:51 +03:00
|
|
|
window.history.replaceState({}, '', `/chat/${chatId}`);
|
|
|
|
|
|
2024-10-11 18:00:22 +05:30
|
|
|
handleSubmit(undefined, {
|
|
|
|
|
experimental_attachments: attachments,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setAttachments([]);
|
2024-10-31 21:02:05 +05:30
|
|
|
setLocalStorageInput('');
|
2025-01-08 17:00:38 +05:30
|
|
|
resetHeight();
|
2024-10-14 23:01:46 +05:30
|
|
|
|
|
|
|
|
if (width && width > 768) {
|
|
|
|
|
textareaRef.current?.focus();
|
|
|
|
|
}
|
2024-11-05 17:15:51 +03:00
|
|
|
}, [
|
|
|
|
|
attachments,
|
|
|
|
|
handleSubmit,
|
|
|
|
|
setAttachments,
|
|
|
|
|
setLocalStorageInput,
|
|
|
|
|
width,
|
|
|
|
|
chatId,
|
|
|
|
|
]);
|
2024-10-11 18:00:22 +05:30
|
|
|
|
|
|
|
|
const uploadFile = async (file: File) => {
|
|
|
|
|
const formData = new FormData();
|
2024-10-24 16:35:51 -04:00
|
|
|
formData.append('file', file);
|
2024-10-11 18:00:22 +05:30
|
|
|
|
|
|
|
|
try {
|
2024-11-15 12:18:17 -05:00
|
|
|
const response = await fetch('/api/files/upload', {
|
2024-10-24 16:35:51 -04:00
|
|
|
method: 'POST',
|
2024-10-11 18:00:22 +05:30
|
|
|
body: formData,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
const { url, pathname, contentType } = data;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
url,
|
|
|
|
|
name: pathname,
|
|
|
|
|
contentType: contentType,
|
|
|
|
|
};
|
|
|
|
|
}
|
2024-11-15 12:18:17 -05:00
|
|
|
const { error } = await response.json();
|
|
|
|
|
toast.error(error);
|
2024-10-11 18:00:22 +05:30
|
|
|
} catch (error) {
|
2024-10-24 16:35:51 -04:00
|
|
|
toast.error('Failed to upload file, please try again!');
|
2024-10-11 18:00:22 +05:30
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleFileChange = useCallback(
|
|
|
|
|
async (event: ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
const files = Array.from(event.target.files || []);
|
|
|
|
|
|
|
|
|
|
setUploadQueue(files.map((file) => file.name));
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const uploadPromises = files.map((file) => uploadFile(file));
|
|
|
|
|
const uploadedAttachments = await Promise.all(uploadPromises);
|
|
|
|
|
const successfullyUploadedAttachments = uploadedAttachments.filter(
|
2024-11-15 13:00:15 -05:00
|
|
|
(attachment) => attachment !== undefined,
|
2024-10-11 18:00:22 +05:30
|
|
|
);
|
|
|
|
|
|
|
|
|
|
setAttachments((currentAttachments) => [
|
|
|
|
|
...currentAttachments,
|
|
|
|
|
...successfullyUploadedAttachments,
|
|
|
|
|
]);
|
|
|
|
|
} catch (error) {
|
2024-10-24 16:35:51 -04:00
|
|
|
console.error('Error uploading files!', error);
|
2024-10-11 18:00:22 +05:30
|
|
|
} finally {
|
|
|
|
|
setUploadQueue([]);
|
|
|
|
|
}
|
|
|
|
|
},
|
2024-11-15 13:00:15 -05:00
|
|
|
[setAttachments],
|
2024-10-11 18:00:22 +05:30
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative w-full flex flex-col gap-4">
|
|
|
|
|
{messages.length === 0 &&
|
|
|
|
|
attachments.length === 0 &&
|
|
|
|
|
uploadQueue.length === 0 && (
|
2024-12-03 17:49:38 +03:00
|
|
|
<SuggestedActions append={append} chatId={chatId} />
|
2024-10-11 18:00:22 +05:30
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<input
|
|
|
|
|
type="file"
|
|
|
|
|
className="fixed -top-4 -left-4 size-0.5 opacity-0 pointer-events-none"
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
multiple
|
|
|
|
|
onChange={handleFileChange}
|
|
|
|
|
tabIndex={-1}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{(attachments.length > 0 || uploadQueue.length > 0) && (
|
2025-03-04 17:25:46 -08:00
|
|
|
<div
|
|
|
|
|
data-testid="attachments-preview"
|
|
|
|
|
className="flex flex-row gap-2 overflow-x-scroll items-end"
|
|
|
|
|
>
|
2024-10-11 18:00:22 +05:30
|
|
|
{attachments.map((attachment) => (
|
|
|
|
|
<PreviewAttachment key={attachment.url} attachment={attachment} />
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{uploadQueue.map((filename) => (
|
|
|
|
|
<PreviewAttachment
|
|
|
|
|
key={filename}
|
|
|
|
|
attachment={{
|
2024-10-24 16:35:51 -04:00
|
|
|
url: '',
|
2024-10-11 18:00:22 +05:30
|
|
|
name: filename,
|
2024-10-24 16:35:51 -04:00
|
|
|
contentType: '',
|
2024-10-11 18:00:22 +05:30
|
|
|
}}
|
|
|
|
|
isUploading={true}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Textarea
|
2025-03-04 17:25:46 -08:00
|
|
|
data-testid="multimodal-input"
|
2024-10-11 18:00:22 +05:30
|
|
|
ref={textareaRef}
|
|
|
|
|
placeholder="Send a message..."
|
|
|
|
|
value={input}
|
|
|
|
|
onChange={handleInput}
|
2024-10-30 16:01:24 +05:30
|
|
|
className={cx(
|
2024-12-17 15:36:51 +05:30
|
|
|
'min-h-[24px] max-h-[calc(75dvh)] overflow-hidden resize-none rounded-2xl !text-base bg-muted pb-10 dark:border-zinc-700',
|
2024-11-15 13:00:15 -05:00
|
|
|
className,
|
2024-10-30 16:01:24 +05:30
|
|
|
)}
|
2024-12-17 15:36:51 +05:30
|
|
|
rows={2}
|
2024-11-05 11:54:57 +03:00
|
|
|
autoFocus
|
2024-10-11 18:00:22 +05:30
|
|
|
onKeyDown={(event) => {
|
2025-03-05 12:46:07 -08:00
|
|
|
if (
|
2025-03-11 14:39:36 -07:00
|
|
|
event.key === 'Enter' &&
|
2025-03-05 12:46:07 -08:00
|
|
|
!event.shiftKey &&
|
|
|
|
|
!event.nativeEvent.isComposing
|
|
|
|
|
) {
|
2024-10-11 18:00:22 +05:30
|
|
|
event.preventDefault();
|
|
|
|
|
|
2025-03-11 15:33:18 -07:00
|
|
|
if (status !== 'ready') {
|
2024-10-24 16:35:51 -04:00
|
|
|
toast.error('Please wait for the model to finish its response!');
|
2024-10-11 18:00:22 +05:30
|
|
|
} else {
|
|
|
|
|
submitForm();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
2024-12-17 15:36:51 +05:30
|
|
|
<div className="absolute bottom-0 p-2 w-fit flex flex-row justify-start">
|
2025-03-11 15:33:18 -07:00
|
|
|
<AttachmentsButton fileInputRef={fileInputRef} status={status} />
|
2024-12-17 15:36:51 +05:30
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="absolute bottom-0 right-0 p-2 w-fit flex flex-row justify-end">
|
2025-03-11 15:33:18 -07:00
|
|
|
{status === 'submitted' ? (
|
2024-12-17 15:36:51 +05:30
|
|
|
<StopButton stop={stop} setMessages={setMessages} />
|
|
|
|
|
) : (
|
|
|
|
|
<SendButton
|
|
|
|
|
input={input}
|
|
|
|
|
submitForm={submitForm}
|
|
|
|
|
uploadQueue={uploadQueue}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2024-10-11 18:00:22 +05:30
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2024-12-03 17:49:38 +03:00
|
|
|
|
|
|
|
|
export const MultimodalInput = memo(
|
|
|
|
|
PureMultimodalInput,
|
2024-12-04 17:27:48 +03:00
|
|
|
(prevProps, nextProps) => {
|
|
|
|
|
if (prevProps.input !== nextProps.input) return false;
|
2025-03-11 15:33:18 -07:00
|
|
|
if (prevProps.status !== nextProps.status) return false;
|
2024-12-04 17:27:48 +03:00
|
|
|
if (!equal(prevProps.attachments, nextProps.attachments)) return false;
|
2024-12-03 17:49:38 +03:00
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
);
|
2024-12-10 17:54:10 +05:30
|
|
|
|
|
|
|
|
function PureAttachmentsButton({
|
|
|
|
|
fileInputRef,
|
2025-03-11 15:33:18 -07:00
|
|
|
status,
|
2024-12-10 17:54:10 +05:30
|
|
|
}: {
|
|
|
|
|
fileInputRef: React.MutableRefObject<HTMLInputElement | null>;
|
2025-03-11 15:33:18 -07:00
|
|
|
status: UseChatHelpers['status'];
|
2024-12-10 17:54:10 +05:30
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<Button
|
2025-03-04 17:25:46 -08:00
|
|
|
data-testid="attachments-button"
|
2024-12-17 15:36:51 +05:30
|
|
|
className="rounded-md rounded-bl-lg p-[7px] h-fit dark:border-zinc-700 hover:dark:bg-zinc-900 hover:bg-zinc-200"
|
2024-12-10 17:54:10 +05:30
|
|
|
onClick={(event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
fileInputRef.current?.click();
|
|
|
|
|
}}
|
2025-03-11 15:33:18 -07:00
|
|
|
disabled={status !== 'ready'}
|
2024-12-17 15:36:51 +05:30
|
|
|
variant="ghost"
|
2024-12-10 17:54:10 +05:30
|
|
|
>
|
2024-12-20 23:07:23 +05:30
|
|
|
<PaperclipIcon size={14} />
|
2024-12-10 17:54:10 +05:30
|
|
|
</Button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const AttachmentsButton = memo(PureAttachmentsButton);
|
|
|
|
|
|
|
|
|
|
function PureStopButton({
|
|
|
|
|
stop,
|
|
|
|
|
setMessages,
|
|
|
|
|
}: {
|
|
|
|
|
stop: () => void;
|
2025-03-20 14:10:45 -07:00
|
|
|
setMessages: UseChatHelpers['setMessages'];
|
2024-12-10 17:54:10 +05:30
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<Button
|
2025-03-04 17:25:46 -08:00
|
|
|
data-testid="stop-button"
|
2024-12-17 15:36:51 +05:30
|
|
|
className="rounded-full p-1.5 h-fit border dark:border-zinc-600"
|
2024-12-10 17:54:10 +05:30
|
|
|
onClick={(event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
stop();
|
2025-03-16 18:42:29 -07:00
|
|
|
setMessages((messages) => messages);
|
2024-12-10 17:54:10 +05:30
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<StopIcon size={14} />
|
|
|
|
|
</Button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const StopButton = memo(PureStopButton);
|
|
|
|
|
|
|
|
|
|
function PureSendButton({
|
|
|
|
|
submitForm,
|
|
|
|
|
input,
|
|
|
|
|
uploadQueue,
|
|
|
|
|
}: {
|
|
|
|
|
submitForm: () => void;
|
|
|
|
|
input: string;
|
|
|
|
|
uploadQueue: Array<string>;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<Button
|
2025-03-04 17:25:46 -08:00
|
|
|
data-testid="send-button"
|
2024-12-17 15:36:51 +05:30
|
|
|
className="rounded-full p-1.5 h-fit border dark:border-zinc-600"
|
2024-12-10 17:54:10 +05:30
|
|
|
onClick={(event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
submitForm();
|
|
|
|
|
}}
|
|
|
|
|
disabled={input.length === 0 || uploadQueue.length > 0}
|
|
|
|
|
>
|
|
|
|
|
<ArrowUpIcon size={14} />
|
|
|
|
|
</Button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SendButton = memo(PureSendButton, (prevProps, nextProps) => {
|
|
|
|
|
if (prevProps.uploadQueue.length !== nextProps.uploadQueue.length)
|
|
|
|
|
return false;
|
2024-12-20 23:07:23 +05:30
|
|
|
if (prevProps.input !== nextProps.input) return false;
|
2024-12-10 17:54:10 +05:30
|
|
|
return true;
|
|
|
|
|
});
|