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