Spaces:
Runtime error
Runtime error
import { | |
IconPlayerStop, | |
IconRepeat, | |
IconSend, | |
} from '@tabler/icons-react'; | |
import { | |
KeyboardEvent, | |
MutableRefObject, | |
useCallback, | |
useContext, | |
useEffect, | |
useRef, | |
useState, | |
} from 'react'; | |
import { Message } from '@/types/chat'; | |
import { Prompt } from '@/types/prompt'; | |
import HomeContext from '@/pages/api/home.context'; | |
interface Props { | |
onSend: (message: Message) => void; | |
onRegenerate: () => void; | |
onScrollDownClick: () => void; | |
stopConversationRef: MutableRefObject<boolean>; | |
textareaRef: MutableRefObject<HTMLTextAreaElement | null>; | |
showScrollDownButton: boolean; | |
} | |
export const ChatInput = ({ | |
onSend, | |
onRegenerate, | |
stopConversationRef, | |
textareaRef, | |
}: Props) => { | |
const { | |
state: { selectedConversation, messageIsStreaming, prompts }, | |
dispatch: homeDispatch, | |
}: any = useContext(HomeContext); | |
const [content, setContent] = useState<string>(); | |
const [isTyping, setIsTyping] = useState<boolean>(false); | |
const [showPromptList, setShowPromptList] = useState(false); | |
const [activePromptIndex, setActivePromptIndex] = useState(0); | |
const [promptInputValue, setPromptInputValue] = useState(''); | |
const [variables, setVariables] = useState<string[]>([]); | |
const promptListRef = useRef<HTMLUListElement | null>(null); | |
const filteredPrompts = prompts.filter((prompt: { name: string; }) => | |
prompt.name.toLowerCase().includes(promptInputValue.toLowerCase()), | |
); | |
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { | |
const value = e.target.value; | |
const maxLength = selectedConversation?.model.maxLength; | |
if (maxLength && value.length > maxLength) { | |
alert( | |
`Message limit is ${maxLength} characters. You have entered ${value.length} characters.`, | |
); | |
return; | |
} | |
setContent(value); | |
updatePromptListVisibility(value); | |
}; | |
const handleSend = () => { | |
if (messageIsStreaming) { | |
return; | |
} | |
if (!content) { | |
alert('Please enter a message'); | |
return; | |
} | |
onSend({ role: 'user', content }); | |
setContent(''); | |
if (window.innerWidth < 640 && textareaRef && textareaRef.current) { | |
textareaRef.current.blur(); | |
} | |
}; | |
const handleStopConversation = () => { | |
stopConversationRef.current = true; | |
setTimeout(() => { | |
stopConversationRef.current = false; | |
}, 1000); | |
}; | |
const isMobile = () => { | |
const userAgent = | |
typeof window.navigator === 'undefined' ? '' : navigator.userAgent; | |
const mobileRegex = | |
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i; | |
return mobileRegex.test(userAgent); | |
}; | |
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { | |
if (e.key === 'Enter' && !isTyping && !isMobile() && !e.shiftKey) { | |
e.preventDefault(); | |
handleSend(); | |
} else if (e.key === '/' && e.metaKey) { | |
e.preventDefault(); | |
} | |
}; | |
const parseVariables = (content: string) => { | |
const regex = /{{(.*?)}}/g; | |
const foundVariables = []; | |
let match; | |
while ((match = regex.exec(content)) !== null) { | |
foundVariables.push(match[1]); | |
} | |
return foundVariables; | |
}; | |
const updatePromptListVisibility = useCallback((text: string) => { | |
const match = text.match(/\/\w*$/); | |
if (match) { | |
setShowPromptList(true); | |
setPromptInputValue(match[0].slice(1)); | |
} else { | |
setShowPromptList(false); | |
setPromptInputValue(''); | |
} | |
}, []); | |
const handlePromptSelect = (prompt: Prompt) => { | |
const parsedVariables = parseVariables(prompt.content); | |
setVariables(parsedVariables); | |
if (parsedVariables.length > 0) { | |
setIsModalVisible(true); | |
} else { | |
setContent((prevContent) => { | |
const updatedContent = prevContent?.replace(/\/\w*$/, prompt.content); | |
return updatedContent; | |
}); | |
updatePromptListVisibility(prompt.content); | |
} | |
}; | |
const handleSubmit = (updatedVariables: string[]) => { | |
const newContent = content?.replace(/{{(.*?)}}/g, (match, variable) => { | |
const index = variables.indexOf(variable); | |
return updatedVariables[index]; | |
}); | |
setContent(newContent); | |
if (textareaRef && textareaRef.current) { | |
textareaRef.current.focus(); | |
} | |
}; | |
useEffect(() => { | |
if (promptListRef.current) { | |
promptListRef.current.scrollTop = activePromptIndex * 30; | |
} | |
}, [activePromptIndex]); | |
useEffect(() => { | |
if (textareaRef && textareaRef.current) { | |
textareaRef.current.style.height = 'inherit'; | |
textareaRef.current.style.height = `${textareaRef.current?.scrollHeight}px`; | |
textareaRef.current.style.overflow = `${ | |
textareaRef?.current?.scrollHeight > 400 ? 'auto' : 'hidden' | |
}`; | |
} | |
}, [content]); | |
useEffect(() => { | |
const handleOutsideClick = (e: MouseEvent) => { | |
if ( | |
promptListRef.current && | |
!promptListRef.current.contains(e.target as Node) | |
) { | |
setShowPromptList(false); | |
} | |
}; | |
window.addEventListener('click', handleOutsideClick); | |
return () => { | |
window.removeEventListener('click', handleOutsideClick); | |
}; | |
}, []); | |
return ( | |
<div className="absolute bottom-0 left-0 w-full border-transparent bg-gradient-to-b from-transparent via-white to-white pt-6 dark:border-white/20 dark:via-[#343541] dark:to-[#343541] md:pt-2"> | |
<div className="stretch mx-2 mt-4 flex flex-row gap-3 last:mb-2 md:mx-4 md:mt-[52px] md:last:mb-6 lg:mx-auto lg:max-w-3xl"> | |
{messageIsStreaming && ( | |
<button | |
className="absolute top-0 left-0 right-0 mx-auto mb-3 flex w-fit items-center gap-3 rounded border border-neutral-200 bg-white py-2 px-4 text-black hover:opacity-50 dark:border-neutral-600 dark:bg-[#343541] dark:text-white md:mb-0 md:mt-2" | |
onClick={handleStopConversation} | |
> | |
<IconPlayerStop size={16} /> {'Stop Generating'} | |
</button> | |
)} | |
{!messageIsStreaming && | |
selectedConversation && | |
selectedConversation.messages.length > 0 && ( | |
<button | |
className="absolute top-0 left-0 right-0 mx-auto mb-3 flex w-fit items-center gap-3 rounded border border-neutral-200 bg-white py-2 px-4 text-black hover:opacity-50 dark:border-neutral-600 dark:bg-[#343541] dark:text-white md:mb-0 md:mt-2" | |
onClick={onRegenerate} | |
> | |
<IconRepeat size={16} /> {'Regenerate response'} | |
</button> | |
)} | |
<div className="relative mx-2 flex w-full flex-grow flex-col rounded-md border border-black/10 bg-white shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:border-gray-900/50 dark:bg-[#40414F] dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] sm:mx-4"> | |
<textarea | |
ref={textareaRef} | |
className="m-0 w-full resize-none border-0 bg-transparent p-0 py-2 pr-8 pl-10 text-black dark:bg-transparent dark:text-white md:py-3 md:pl-10" | |
style={{ | |
resize: 'none', | |
bottom: `${textareaRef?.current?.scrollHeight}px`, | |
maxHeight: '400px', | |
overflow: `${ | |
textareaRef.current && textareaRef.current.scrollHeight > 400 | |
? 'auto' | |
: 'hidden' | |
}`, | |
}} | |
placeholder={ | |
'Type a message or type "/" to select a prompt...' || '' | |
} | |
value={content} | |
rows={1} | |
onCompositionStart={() => setIsTyping(true)} | |
onCompositionEnd={() => setIsTyping(false)} | |
onChange={handleChange} | |
onKeyDown={handleKeyDown} | |
/> | |
<button | |
className="absolute right-2 top-2 rounded-sm p-1 text-neutral-800 opacity-60 hover:bg-neutral-200 hover:text-neutral-900 dark:bg-opacity-50 dark:text-neutral-100 dark:hover:text-neutral-200" | |
onClick={handleSend} | |
> | |
{messageIsStreaming ? ( | |
<div className="h-4 w-4 animate-spin rounded-full border-t-2 border-neutral-800 opacity-60 dark:border-neutral-100"></div> | |
) : ( | |
<IconSend size={18} /> | |
)} | |
</button> | |
</div> | |
</div> | |
<div className="px-3 pt-2 pb-3 text-center text-[12px] text-black/50 dark:text-white/50 md:px-4 md:pt-3 md:pb-6"> | |
</div> | |
</div> | |
); | |
}; |