91cbe0b104
* fix: scroll button not visible in light mode * fix: sidebar when there is a folder When a folder is added in the sidebar and there are less items scroll bar appears. This simple change fixes that behaviour * fix: small devices regerate/Stop button below input Below 768px Stop Genrating and Regerate Button remains hidden behind the input. This is the fix for that
332 lines
10 KiB
TypeScript
332 lines
10 KiB
TypeScript
import { Message } from '@/types/chat';
|
|
import { OpenAIModel } from '@/types/openai';
|
|
import { Prompt } from '@/types/prompt';
|
|
import { IconPlayerStop, IconRepeat, IconSend } from '@tabler/icons-react';
|
|
import { useTranslation } from 'next-i18next';
|
|
import {
|
|
FC,
|
|
KeyboardEvent,
|
|
MutableRefObject,
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { PromptList } from './PromptList';
|
|
import { VariableModal } from './VariableModal';
|
|
|
|
interface Props {
|
|
messageIsStreaming: boolean;
|
|
model: OpenAIModel;
|
|
conversationIsEmpty: boolean;
|
|
messages: Message[];
|
|
prompts: Prompt[];
|
|
onSend: (message: Message) => void;
|
|
onRegenerate: () => void;
|
|
stopConversationRef: MutableRefObject<boolean>;
|
|
textareaRef: MutableRefObject<HTMLTextAreaElement | null>;
|
|
}
|
|
|
|
export const ChatInput: FC<Props> = ({
|
|
messageIsStreaming,
|
|
model,
|
|
conversationIsEmpty,
|
|
messages,
|
|
prompts,
|
|
onSend,
|
|
onRegenerate,
|
|
stopConversationRef,
|
|
textareaRef,
|
|
}) => {
|
|
const { t } = useTranslation('chat');
|
|
|
|
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 [isModalVisible, setIsModalVisible] = useState(false);
|
|
|
|
const promptListRef = useRef<HTMLUListElement | null>(null);
|
|
|
|
const filteredPrompts = prompts.filter((prompt) =>
|
|
prompt.name.toLowerCase().includes(promptInputValue.toLowerCase()),
|
|
);
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const value = e.target.value;
|
|
const maxLength = model.maxLength;
|
|
|
|
if (value.length > maxLength) {
|
|
alert(
|
|
t(
|
|
`Message limit is {{maxLength}} characters. You have entered {{valueLength}} characters.`,
|
|
{ maxLength, valueLength: value.length },
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setContent(value);
|
|
updatePromptListVisibility(value);
|
|
};
|
|
|
|
const handleSend = () => {
|
|
if (messageIsStreaming) {
|
|
return;
|
|
}
|
|
|
|
if (!content) {
|
|
alert(t('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 handleInitModal = () => {
|
|
const selectedPrompt = filteredPrompts[activePromptIndex];
|
|
if (selectedPrompt) {
|
|
setContent((prevContent) => {
|
|
const newContent = prevContent?.replace(
|
|
/\/\w*$/,
|
|
selectedPrompt.content,
|
|
);
|
|
return newContent;
|
|
});
|
|
handlePromptSelect(selectedPrompt);
|
|
}
|
|
setShowPromptList(false);
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (showPromptList) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setActivePromptIndex((prevIndex) =>
|
|
prevIndex < prompts.length - 1 ? prevIndex + 1 : prevIndex,
|
|
);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setActivePromptIndex((prevIndex) =>
|
|
prevIndex > 0 ? prevIndex - 1 : prevIndex,
|
|
);
|
|
} else if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
setActivePromptIndex((prevIndex) =>
|
|
prevIndex < prompts.length - 1 ? prevIndex + 1 : 0,
|
|
);
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleInitModal();
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
setShowPromptList(false);
|
|
} else {
|
|
setActivePromptIndex(0);
|
|
}
|
|
} else if (e.key === 'Enter' && !isTyping && !isMobile() && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
};
|
|
|
|
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 mb-3 md:mb-0 md:mt-2 mx-auto 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"
|
|
onClick={handleStopConversation}
|
|
>
|
|
<IconPlayerStop size={16} /> {t('Stop Generating')}
|
|
</button>
|
|
)}
|
|
|
|
{!messageIsStreaming && !conversationIsEmpty && (
|
|
<button
|
|
className="absolute top-0 left-0 right-0 mb-3 md:mb-0 md:mt-2 mx-auto 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"
|
|
onClick={onRegenerate}
|
|
>
|
|
<IconRepeat size={16} /> {t('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-2 text-black dark:bg-transparent dark:text-white md:py-3 md:pl-4"
|
|
style={{
|
|
resize: 'none',
|
|
bottom: `${textareaRef?.current?.scrollHeight}px`,
|
|
maxHeight: '400px',
|
|
overflow: `${textareaRef.current && textareaRef.current.scrollHeight > 400
|
|
? 'auto' : 'hidden'
|
|
}`,
|
|
}}
|
|
placeholder={
|
|
t('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>
|
|
|
|
{showPromptList && filteredPrompts.length > 0 && (
|
|
<div className="absolute bottom-12 w-full">
|
|
<PromptList
|
|
activePromptIndex={activePromptIndex}
|
|
prompts={filteredPrompts}
|
|
onSelect={handleInitModal}
|
|
onMouseOver={setActivePromptIndex}
|
|
promptListRef={promptListRef}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{isModalVisible && (
|
|
<VariableModal
|
|
prompt={prompts[activePromptIndex]}
|
|
variables={variables}
|
|
onSubmit={handleSubmit}
|
|
onClose={() => setIsModalVisible(false)}
|
|
/>
|
|
)}
|
|
</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">
|
|
<a
|
|
href="https://github.com/mckaywrigley/chatbot-ui"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="underline"
|
|
>
|
|
ChatBot UI
|
|
</a>
|
|
.{' '}
|
|
{t(
|
|
"Chatbot UI is an advanced chatbot kit for OpenAI's chat models aiming to mimic ChatGPT's interface and functionality.",
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|