New Input Bar and few UI fixes
This commit is contained in:
parent
fa967fbc0c
commit
c90e72664b
@ -19,7 +19,7 @@ import Marked, { ReactRenderer } from 'marked-react';
|
||||
import { track } from '@vercel/analytics';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useChat } from 'ai/react';
|
||||
import { ToolInvocation } from 'ai';
|
||||
import { ChatRequestOptions, CreateMessage, Message, ToolInvocation } from 'ai';
|
||||
import { toast } from 'sonner';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
@ -122,6 +122,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import Autoplay from 'embla-carousel-autoplay';
|
||||
import FormComponent from '@/components/ui/form-component';
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
@ -155,13 +156,13 @@ const HomeContent = () => {
|
||||
const [editingMessageIndex, setEditingMessageIndex] = useState(-1);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { theme } = useTheme();
|
||||
|
||||
const [openChangelog, setOpenChangelog] = useState(false);
|
||||
|
||||
const { isLoading, input, messages, setInput, handleInputChange, append, handleSubmit, setMessages, reload } = useChat({
|
||||
const { isLoading, input, messages, setInput, handleInputChange, append, handleSubmit, setMessages, reload, stop } = useChat({
|
||||
maxToolRoundtrips: selectedModel === 'mistral:pixtral-12b-2409' ? 1 : 2,
|
||||
body: {
|
||||
model: selectedModel,
|
||||
@ -1746,342 +1747,18 @@ The most requested feature is finally here! You can now toggle between light and
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface AttachmentPreviewProps {
|
||||
attachment: Attachment | UploadingAttachment;
|
||||
onRemove: () => void;
|
||||
isUploading: boolean;
|
||||
}
|
||||
|
||||
const AttachmentPreview: React.FC<AttachmentPreviewProps> = React.memo(({ attachment, onRemove, isUploading }) => {
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' bytes';
|
||||
else if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
else return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const isUploadingAttachment = (attachment: Attachment | UploadingAttachment): attachment is UploadingAttachment => {
|
||||
return 'progress' in attachment;
|
||||
};
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="relative flex items-center bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-2xl p-2 pr-8 gap-2 cursor-pointer shadow-sm !z-30"
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="w-10 h-10 flex items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-neutral-500 dark:text-neutral-400" />
|
||||
</div>
|
||||
) : isUploadingAttachment(attachment) ? (
|
||||
<div className="w-10 h-10 flex items-center justify-center">
|
||||
<div className="relative w-8 h-8">
|
||||
<svg className="w-full h-full" viewBox="0 0 100 100">
|
||||
<circle
|
||||
className="text-neutral-300 dark:text-neutral-600 stroke-current"
|
||||
strokeWidth="10"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="transparent"
|
||||
></circle>
|
||||
<circle
|
||||
className="text-primary stroke-current"
|
||||
strokeWidth="10"
|
||||
strokeLinecap="round"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="transparent"
|
||||
strokeDasharray={`${attachment.progress * 251.2}, 251.2`}
|
||||
transform="rotate(-90 50 50)"
|
||||
></circle>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-semibold text-neutral-800 dark:text-neutral-200">{Math.round(attachment.progress * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={(attachment as Attachment).url}
|
||||
alt={`Preview of ${attachment.name}`}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-lg h-10 w-10 object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-grow min-w-0">
|
||||
{!isUploadingAttachment(attachment) && (
|
||||
<p className="text-sm font-medium truncate text-neutral-800 dark:text-neutral-200">{attachment.name}</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{isUploadingAttachment(attachment)
|
||||
? 'Uploading...'
|
||||
: formatFileSize((attachment as Attachment).size)}
|
||||
</p>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
className="absolute -top-2 -right-2 p-0.5 m-0 rounded-full bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 shadow-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors z-20"
|
||||
>
|
||||
<X size={14} className="text-neutral-500 dark:text-neutral-400" />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</HoverCardTrigger>
|
||||
{!isUploadingAttachment(attachment) && (
|
||||
<HoverCardContent className="w-fit p-1 bg-white dark:bg-neutral-800 border-none rounded-xl !z-40">
|
||||
<Image
|
||||
src={(attachment as Attachment).url}
|
||||
alt={`Full preview of ${attachment.name}`}
|
||||
width={300}
|
||||
height={300}
|
||||
objectFit="contain"
|
||||
className="rounded-md"
|
||||
/>
|
||||
</HoverCardContent>
|
||||
)}
|
||||
</HoverCard>
|
||||
);
|
||||
});
|
||||
|
||||
AttachmentPreview.displayName = 'AttachmentPreview';
|
||||
|
||||
interface FormComponentProps {
|
||||
input: string;
|
||||
setInput: (input: string) => void;
|
||||
attachments: Attachment[];
|
||||
setAttachments: React.Dispatch<React.SetStateAction<Attachment[]>>;
|
||||
hasSubmitted: boolean;
|
||||
setHasSubmitted: (value: boolean) => void;
|
||||
isLoading: boolean;
|
||||
handleSubmit: (event: React.FormEvent<HTMLFormElement>, options?: { experimental_attachments?: Attachment[] }) => void;
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const FormComponent: React.FC<FormComponentProps> = ({
|
||||
input,
|
||||
setInput,
|
||||
attachments,
|
||||
setAttachments,
|
||||
hasSubmitted,
|
||||
setHasSubmitted,
|
||||
isLoading,
|
||||
handleSubmit,
|
||||
fileInputRef,
|
||||
inputRef,
|
||||
}) => {
|
||||
const [uploadingAttachments, setUploadingAttachments] = useState<UploadingAttachment[]>([]);
|
||||
|
||||
const uploadFile = async (file: File): Promise<Attachment> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload file');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = event.target.files;
|
||||
if (selectedFiles) {
|
||||
const imageFiles = Array.from(selectedFiles).filter(file => file.type.startsWith('image/'));
|
||||
if (imageFiles.length > 0) {
|
||||
if (imageFiles.length + attachments.length + uploadingAttachments.length > MAX_IMAGES) {
|
||||
toast.error(`You can only attach up to ${MAX_IMAGES} images.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newUploadingAttachments = imageFiles.map(file => ({ file, progress: 0 }));
|
||||
setUploadingAttachments(prev => [...prev, ...newUploadingAttachments]);
|
||||
|
||||
for (const file of imageFiles) {
|
||||
try {
|
||||
const uploadedFile = await uploadFile(file);
|
||||
setAttachments(prev => [...prev, uploadedFile]);
|
||||
setUploadingAttachments(prev => prev.filter(ua => ua.file !== file));
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
toast.error(`Failed to upload ${file.name}`);
|
||||
setUploadingAttachments(prev => prev.filter(ua => ua.file !== file));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast.error("Please select image files only.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
setAttachments(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const removeUploadingAttachment = (index: number) => {
|
||||
setUploadingAttachments(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (input.trim() || (selectedModel !== 'openai/o1-mini' && attachments.length > 0)) {
|
||||
track("search enter", { query: input.trim() });
|
||||
setHasSubmitted(true);
|
||||
|
||||
handleSubmit(event, {
|
||||
experimental_attachments: attachments,
|
||||
});
|
||||
|
||||
setAttachments([]);
|
||||
setUploadingAttachments([]);
|
||||
setSuggestedQuestions([]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
} else {
|
||||
toast.error("Please enter a search query or attach an image.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<motion.form
|
||||
layout
|
||||
onSubmit={onSubmit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmit(e);
|
||||
}
|
||||
}}
|
||||
onDrag={e => e.preventDefault()}
|
||||
onDrop={e => {
|
||||
e.preventDefault();
|
||||
handleFileChange({ target: { files: e.dataTransfer?.files } } as React.ChangeEvent<HTMLInputElement>);
|
||||
}}
|
||||
className={`
|
||||
${hasSubmitted ? 'fixed bottom-4 left-1/2 -translate-x-1/2 max-w-[90%] sm:max-w-2xl' : 'max-w-full'}
|
||||
${attachments.length > 0 || uploadingAttachments.length > 0 ? 'rounded-2xl' : 'rounded-xl'}
|
||||
w-full
|
||||
bg-background border border-input dark:border-neutral-700
|
||||
overflow-hidden mb-4
|
||||
transition-all duration-300 ease-in-out
|
||||
z-50
|
||||
`}
|
||||
>
|
||||
<div className={cn('w-full space-y-2', attachments.length > 0 || uploadingAttachments.length > 0 ? 'p-2' : 'p-0')}>
|
||||
<AnimatePresence initial={false}>
|
||||
{selectedModel !== 'openai/o1-mini' && (attachments.length > 0 || uploadingAttachments.length > 0) && (
|
||||
<motion.div
|
||||
key="file-previews"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex flex-wrap gap-2 z-30 relative"
|
||||
>
|
||||
{uploadingAttachments.map((attachment, index) => (
|
||||
<AttachmentPreview
|
||||
key={`uploading-${index}`}
|
||||
attachment={attachment}
|
||||
onRemove={() => removeUploadingAttachment(index)}
|
||||
isUploading={true}
|
||||
/>
|
||||
))}
|
||||
{attachments.map((attachment, index) => (
|
||||
<AttachmentPreview
|
||||
key={attachment.url}
|
||||
attachment={attachment}
|
||||
onRemove={() => removeAttachment(index)}
|
||||
isUploading={false}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="relative flex items-center z-20 w-full">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
name="search"
|
||||
placeholder={hasSubmitted ? "Ask a new question..." : "Ask a question..."}
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"w-full min-h-[48px] max-h-[200px] h-full pr-12 bg-muted pl-10",
|
||||
"ring-offset-background focus-visible:outline-none focus-visible:ring-0 border-none",
|
||||
"text-sm sm:text-base rounded-md overflow-hidden",
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSubmit(e as any);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={hasSubmitted ? "file-upload-bottom" : "file-upload-top"}
|
||||
className={`absolute left-3 cursor-pointer ${attachments.length + uploadingAttachments.length >= MAX_IMAGES ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<Paperclip className="h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
id={hasSubmitted ? "file-upload-bottom" : "file-upload-top"}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
disabled={attachments.length + uploadingAttachments.length >= MAX_IMAGES}
|
||||
ref={fileInputRef}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="absolute right-2"
|
||||
disabled={input.trim().length === 0 || isLoading || uploadingAttachments.length > 0}
|
||||
>
|
||||
<ArrowRight size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const SuggestionCards: React.FC<{ selectedModel: string }> = ({ selectedModel }) => {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-grow sm:flex-row gap-2 sm:gap-4 sm:mx-auto w-full">
|
||||
<div className="flex gap-3 mt-4">
|
||||
<div className="flex flex-grow sm:flex-row sm:mx-auto w-full gap-2 sm:gap-[21px]">
|
||||
{suggestionCards.map((card, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleExampleClick(card)}
|
||||
className="bg-neutral-100 dark:bg-neutral-800 rounded-xl py-3 sm:py-4 px-4 text-left hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors duration-200"
|
||||
className="bg-neutral-100 dark:bg-neutral-800 rounded-xl p-2 sm:p-4 text-left hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors duration-200"
|
||||
>
|
||||
<div className="flex items-center space-x-2 text-neutral-700 dark:text-neutral-300">
|
||||
<span>{card.icon}</span>
|
||||
@ -2098,7 +1775,6 @@ The most requested feature is finally here! You can now toggle between light and
|
||||
|
||||
const models = [
|
||||
{ value: "azure:gpt4o-mini", label: "OpenAI", icon: Zap, description: "High speed, lower quality", color: "emerald" },
|
||||
{ value: "mistral:pixtral-12b-2409", label: "Mistral", icon: Camera, description: "Pixtral 12B", color: "blue" },
|
||||
{ value: "anthropicVertex:claude-3-5-sonnet@20240620", label: "Claude", icon: Sparkles, description: "High quality, lower speed", color: "indigo" },
|
||||
]
|
||||
|
||||
@ -2216,11 +1892,11 @@ The most requested feature is finally here! You can now toggle between light and
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
{!hasSubmitted &&
|
||||
{!hasSubmitted && (
|
||||
<div className="flex items-center justify-between !-mb-2">
|
||||
<ModelSwitcher selectedModel={selectedModel} setSelectedModel={handleModelChange} />
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{!hasSubmitted && (
|
||||
<motion.div
|
||||
@ -2235,16 +1911,20 @@ The most requested feature is finally here! You can now toggle between light and
|
||||
setAttachments={setAttachments}
|
||||
hasSubmitted={hasSubmitted}
|
||||
setHasSubmitted={setHasSubmitted}
|
||||
handleSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
handleSubmit={handleSubmit}
|
||||
fileInputRef={fileInputRef}
|
||||
inputRef={inputRef}
|
||||
stop={stop}
|
||||
messages={messages}
|
||||
append={append}
|
||||
/>
|
||||
<SuggestionCards selectedModel={selectedModel} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
<div className="space-y-4 sm:space-y-6 mb-32">
|
||||
{messages.map((message, index) => (
|
||||
<div key={index}>
|
||||
@ -2380,6 +2060,13 @@ The most requested feature is finally here! You can now toggle between light and
|
||||
|
||||
<AnimatePresence>
|
||||
{hasSubmitted && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="fixed bottom-4 w-full max-w-[90%] sm:max-w-2xl"
|
||||
>
|
||||
<FormComponent
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
@ -2387,11 +2074,15 @@ The most requested feature is finally here! You can now toggle between light and
|
||||
setAttachments={setAttachments}
|
||||
hasSubmitted={hasSubmitted}
|
||||
setHasSubmitted={setHasSubmitted}
|
||||
handleSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
handleSubmit={handleSubmit}
|
||||
fileInputRef={fileInputRef}
|
||||
inputRef={inputRef}
|
||||
stop={stop}
|
||||
messages={messages}
|
||||
append={append}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ChangeLogs open={openChangelog} setOpen={setOpenChangelog} />
|
||||
|
||||
420
components/ui/form-component.tsx
Normal file
420
components/ui/form-component.tsx
Normal file
@ -0,0 +1,420 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChatRequestOptions, CreateMessage, Message } from 'ai';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '../ui/button';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import useWindowSize from '@/hooks/use-window-size';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface Attachment {
|
||||
name: string;
|
||||
contentType: string;
|
||||
url: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const ArrowUpIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8.70711 1.39644C8.31659 1.00592 7.68342 1.00592 7.2929 1.39644L2.21968 6.46966L1.68935 6.99999L2.75001 8.06065L3.28034 7.53032L7.25001 3.56065V14.25V15H8.75001V14.25V3.56065L12.7197 7.53032L13.25 8.06065L14.3107 6.99999L13.7803 6.46966L8.70711 1.39644Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const StopIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3 3H13V13H3V3Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const PaperclipIcon = ({ size = 16 }: { size?: number }) => {
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
style={{ color: "currentcolor" }}
|
||||
className="-rotate-45"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.8591 1.70735C10.3257 1.70735 9.81417 1.91925 9.437 2.29643L3.19455 8.53886C2.56246 9.17095 2.20735 10.0282 2.20735 10.9222C2.20735 11.8161 2.56246 12.6734 3.19455 13.3055C3.82665 13.9376 4.68395 14.2927 5.57786 14.2927C6.47178 14.2927 7.32908 13.9376 7.96117 13.3055L14.2036 7.06304L14.7038 6.56287L15.7041 7.56321L15.204 8.06337L8.96151 14.3058C8.06411 15.2032 6.84698 15.7074 5.57786 15.7074C4.30875 15.7074 3.09162 15.2032 2.19422 14.3058C1.29682 13.4084 0.792664 12.1913 0.792664 10.9222C0.792664 9.65305 1.29682 8.43592 2.19422 7.53852L8.43666 1.29609C9.07914 0.653606 9.95054 0.292664 10.8591 0.292664C11.7678 0.292664 12.6392 0.653606 13.2816 1.29609C13.9241 1.93857 14.2851 2.80997 14.2851 3.71857C14.2851 4.62718 13.9241 5.49858 13.2816 6.14106L13.2814 6.14133L7.0324 12.3835C7.03231 12.3836 7.03222 12.3837 7.03213 12.3838C6.64459 12.7712 6.11905 12.9888 5.57107 12.9888C5.02297 12.9888 4.49731 12.7711 4.10974 12.3835C3.72217 11.9959 3.50444 11.4703 3.50444 10.9222C3.50444 10.3741 3.72217 9.8484 4.10974 9.46084L4.11004 9.46054L9.877 3.70039L10.3775 3.20051L11.3772 4.20144L10.8767 4.70131L5.11008 10.4612C5.11005 10.4612 5.11003 10.4612 5.11 10.4613C4.98779 10.5835 4.91913 10.7493 4.91913 10.9222C4.91913 11.0951 4.98782 11.2609 5.11008 11.3832C5.23234 11.5054 5.39817 11.5741 5.57107 11.5741C5.74398 11.5741 5.9098 11.5054 6.03206 11.3832L6.03233 11.3829L12.2813 5.14072C12.2814 5.14063 12.2815 5.14054 12.2816 5.14045C12.6586 4.7633 12.8704 4.25185 12.8704 3.71857C12.8704 3.18516 12.6585 2.6736 12.2813 2.29643C11.9041 1.91925 11.3926 1.70735 10.8591 1.70735Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const MAX_IMAGES = 3;
|
||||
|
||||
interface FormComponentProps {
|
||||
input: string;
|
||||
setInput: (input: string) => void;
|
||||
attachments: Array<Attachment>;
|
||||
setAttachments: React.Dispatch<React.SetStateAction<Array<Attachment>>>;
|
||||
hasSubmitted: boolean;
|
||||
setHasSubmitted: (value: boolean) => void;
|
||||
isLoading: boolean;
|
||||
handleSubmit: (
|
||||
event?: {
|
||||
preventDefault?: () => void;
|
||||
},
|
||||
chatRequestOptions?: ChatRequestOptions,
|
||||
) => void;
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
inputRef: React.RefObject<HTMLTextAreaElement>;
|
||||
stop: () => void;
|
||||
messages: Array<Message>;
|
||||
append: (
|
||||
message: Message | CreateMessage,
|
||||
chatRequestOptions?: ChatRequestOptions,
|
||||
) => Promise<string | null | undefined>;
|
||||
}
|
||||
|
||||
const AttachmentPreview: React.FC<{ attachment: Attachment | UploadingAttachment, onRemove: () => void, isUploading: boolean }> = ({ attachment, onRemove, isUploading }) => {
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' bytes';
|
||||
else if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
else return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const isUploadingAttachment = (attachment: Attachment | UploadingAttachment): attachment is UploadingAttachment => {
|
||||
return 'progress' in attachment;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="relative flex items-center bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg p-2 pr-8 gap-2 cursor-pointer shadow-sm flex-shrink-0"
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="w-10 h-10 flex items-center justify-center">
|
||||
<svg className="animate-spin h-5 w-5 text-neutral-500 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
) : isUploadingAttachment(attachment) ? (
|
||||
<div className="w-10 h-10 flex items-center justify-center">
|
||||
<div className="relative w-8 h-8">
|
||||
<svg className="w-full h-full" viewBox="0 0 100 100">
|
||||
<circle
|
||||
className="text-neutral-300 dark:text-neutral-600 stroke-current"
|
||||
strokeWidth="10"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="transparent"
|
||||
></circle>
|
||||
<circle
|
||||
className="text-primary stroke-current"
|
||||
strokeWidth="10"
|
||||
strokeLinecap="round"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="transparent"
|
||||
strokeDasharray={`${attachment.progress * 251.2}, 251.2`}
|
||||
transform="rotate(-90 50 50)"
|
||||
></circle>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-semibold text-neutral-800 dark:text-neutral-200">{Math.round(attachment.progress * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={(attachment as Attachment).url}
|
||||
alt={`Preview of ${attachment.name}`}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-lg h-10 w-10 object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-grow min-w-0">
|
||||
{!isUploadingAttachment(attachment) && (
|
||||
<p className="text-sm font-medium truncate text-neutral-800 dark:text-neutral-200">{attachment.name}</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{isUploadingAttachment(attachment)
|
||||
? 'Uploading...'
|
||||
: formatFileSize((attachment as Attachment).size)}
|
||||
</p>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
className="absolute -top-2 -right-2 p-0.5 m-0 rounded-full bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 shadow-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors z-20"
|
||||
>
|
||||
<X className="h-4 w-4 text-neutral-500 dark:text-neutral-400" />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
interface UploadingAttachment {
|
||||
file: File;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
const FormComponent: React.FC<FormComponentProps> = ({
|
||||
input,
|
||||
setInput,
|
||||
attachments,
|
||||
setAttachments,
|
||||
hasSubmitted,
|
||||
setHasSubmitted,
|
||||
isLoading,
|
||||
handleSubmit,
|
||||
fileInputRef,
|
||||
inputRef,
|
||||
stop
|
||||
}) => {
|
||||
const [uploadingAttachments, setUploadingAttachments] = useState<UploadingAttachment[]>([]);
|
||||
const [uploadQueue, setUploadQueue] = useState<Array<string>>([]);
|
||||
const { width } = useWindowSize();
|
||||
const postSubmitFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = "auto";
|
||||
inputRef.current.style.height = `${inputRef.current.scrollHeight + 2}px`;
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
adjustHeight();
|
||||
}
|
||||
}, [adjustHeight, input, inputRef]);
|
||||
|
||||
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInput(event.target.value);
|
||||
adjustHeight();
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File): Promise<Attachment> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} else {
|
||||
throw new Error('Failed to upload file');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
toast.error("Failed to upload file, please try again!");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = useCallback(async (event: React.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);
|
||||
setAttachments((currentAttachments) => [
|
||||
...currentAttachments,
|
||||
...uploadedAttachments,
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error uploading files!", error);
|
||||
} finally {
|
||||
setUploadQueue([]);
|
||||
event.target.value = '';
|
||||
}
|
||||
}, [setAttachments]);
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
setAttachments(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const onSubmit = useCallback((event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (input.trim() || attachments.length > 0) {
|
||||
setHasSubmitted(true);
|
||||
|
||||
handleSubmit(event, {
|
||||
experimental_attachments: attachments,
|
||||
});
|
||||
|
||||
setAttachments([]);
|
||||
setUploadingAttachments([]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
} else {
|
||||
toast.error("Please enter a search query or attach an image.");
|
||||
}
|
||||
}, [input, attachments, setHasSubmitted, handleSubmit, setAttachments, setUploadingAttachments, fileInputRef]);
|
||||
|
||||
const submitForm = useCallback(() => {
|
||||
onSubmit({ preventDefault: () => { }, stopPropagation: () => { } } as React.FormEvent<HTMLFormElement>);
|
||||
|
||||
if (width && width > 768) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [onSubmit, width, inputRef]);
|
||||
|
||||
const triggerFileInput = useCallback(() => {
|
||||
if (hasSubmitted) {
|
||||
postSubmitFileInputRef.current?.click();
|
||||
} else {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}, [hasSubmitted, fileInputRef]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="relative w-full flex flex-col gap-4">
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={fileInputRef}
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={postSubmitFileInputRef}
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
|
||||
{(attachments.length > 0 || uploadQueue.length > 0) && (
|
||||
<div className="flex flex-row gap-2 overflow-x-auto py-2 max-h-32 z-10">
|
||||
{attachments.map((attachment, index) => (
|
||||
<AttachmentPreview
|
||||
key={attachment.url}
|
||||
attachment={attachment}
|
||||
onRemove={() => removeAttachment(index)}
|
||||
isUploading={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{uploadQueue.map((filename) => (
|
||||
<AttachmentPreview
|
||||
key={filename}
|
||||
attachment={{
|
||||
url: "",
|
||||
name: filename,
|
||||
contentType: "",
|
||||
size: 0,
|
||||
} as Attachment}
|
||||
onRemove={() => { }}
|
||||
isUploading={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
placeholder={hasSubmitted ? "Ask a new question..." : "Ask a question..."}
|
||||
value={input}
|
||||
onChange={handleInput}
|
||||
className="min-h-[24px] overflow-hidden resize-none rounded-lg text-base bg-muted"
|
||||
rows={3}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
if (isLoading) {
|
||||
toast.error("Please wait for the model to finish its response!");
|
||||
} else {
|
||||
submitForm();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Button
|
||||
className="rounded-full p-1.5 h-fit absolute bottom-2 right-2 m-0.5"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
stop();
|
||||
}}
|
||||
>
|
||||
<StopIcon size={14} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="rounded-full p-1.5 h-fit absolute bottom-2 right-2 m-0.5"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
submitForm();
|
||||
}}
|
||||
disabled={input.length === 0 && attachments.length === 0 || uploadQueue.length > 0}
|
||||
>
|
||||
<ArrowUpIcon size={14} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="rounded-full p-1.5 h-fit absolute bottom-2 right-10 m-0.5 dark:border-zinc-700"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
triggerFileInput();
|
||||
}}
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<PaperclipIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormComponent;
|
||||
39
hooks/use-window-size.tsx
Normal file
39
hooks/use-window-size.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
interface WindowSize {
|
||||
width: number | undefined;
|
||||
height: number | undefined;
|
||||
}
|
||||
|
||||
function useWindowSize(): WindowSize {
|
||||
const [windowSize, setWindowSize] = useState<WindowSize>({
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Handler to call on window resize
|
||||
function handleResize() {
|
||||
// Set window width/height to state
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// Call handler right away so state gets updated with initial window size
|
||||
handleResize();
|
||||
|
||||
// Remove event listener on cleanup
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []); // Empty array ensures that effect is only run on mount and unmount
|
||||
|
||||
return windowSize;
|
||||
}
|
||||
|
||||
export default useWindowSize;
|
||||
Loading…
Reference in New Issue
Block a user