Skip to content

Commit

Permalink
Prompts (mckaywrigley#229)
Browse files Browse the repository at this point in the history
  • Loading branch information
mckaywrigley authored Mar 27, 2023
1 parent 2269403 commit 34c79c0
Show file tree
Hide file tree
Showing 51 changed files with 1,743 additions and 294 deletions.
54 changes: 42 additions & 12 deletions components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import {
Conversation,
ErrorMessage,
KeyValuePair,
Message,
OpenAIModel,
} from '@/types';
import { Conversation, Message } from '@/types/chat';
import { KeyValuePair } from '@/types/data';
import { ErrorMessage } from '@/types/error';
import { OpenAIModel } from '@/types/openai';
import { Prompt } from '@/types/prompt';
import { throttle } from '@/utils';
import { IconClearAll, IconKey, IconSettings } from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { FC, memo, MutableRefObject, useEffect, useRef, useState } from 'react';
import {
FC,
memo,
MutableRefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { Spinner } from '../Global/Spinner';
import { ChatInput } from './ChatInput';
import { ChatLoader } from './ChatLoader';
Expand All @@ -24,8 +30,8 @@ interface Props {
serverSideApiKeyIsSet: boolean;
messageIsStreaming: boolean;
modelError: ErrorMessage | null;
messageError: boolean;
loading: boolean;
prompts: Prompt[];
onSend: (message: Message, deleteCount?: number) => void;
onUpdateConversation: (
conversation: Conversation,
Expand All @@ -43,8 +49,8 @@ export const Chat: FC<Props> = memo(
serverSideApiKeyIsSet,
messageIsStreaming,
modelError,
messageError,
loading,
prompts,
onSend,
onUpdateConversation,
onEditMessage,
Expand All @@ -59,6 +65,27 @@ export const Chat: FC<Props> = memo(
const chatContainerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);

const scrollToBottom = useCallback(() => {
if (autoScrollEnabled) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
textareaRef.current?.focus();
}
}, [autoScrollEnabled]);

const handleScroll = () => {
if (chatContainerRef.current) {
const { scrollTop, scrollHeight, clientHeight } =
chatContainerRef.current;
const bottomTolerance = 30;

if (scrollTop + clientHeight < scrollHeight - bottomTolerance) {
setAutoScrollEnabled(false);
} else {
setAutoScrollEnabled(true);
}
}
};

const handleSettings = () => {
setShowSettings(!showSettings);
};
Expand Down Expand Up @@ -174,6 +201,7 @@ export const Chat: FC<Props> = memo(

<SystemPrompt
conversation={conversation}
prompts={prompts}
onChangePrompt={(prompt) =>
onUpdateConversation(conversation, {
key: 'prompt',
Expand Down Expand Up @@ -201,8 +229,8 @@ export const Chat: FC<Props> = memo(
/>
</div>
{showSettings && (
<div className="flex flex-col space-y-10 md:max-w-xl md:gap-6 md:py-3 md:pt-6 md:mx-auto lg:max-w-2xl lg:px-0 xl:max-w-3xl">
<div className="flex h-full flex-col space-y-4 border-b md:rounded-lg md:border border-neutral-200 p-4 dark:border-neutral-600">
<div className="flex flex-col space-y-10 md:mx-auto md:max-w-xl md:gap-6 md:py-3 md:pt-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
<div className="flex h-full flex-col space-y-4 border-b border-neutral-200 p-4 dark:border-neutral-600 md:rounded-lg md:border">
<ModelSelect
model={conversation.model}
models={models}
Expand Down Expand Up @@ -241,7 +269,9 @@ export const Chat: FC<Props> = memo(
textareaRef={textareaRef}
messageIsStreaming={messageIsStreaming}
conversationIsEmpty={conversation.messages.length === 0}
messages={conversation.messages}
model={conversation.model}
prompts={prompts}
onSend={(message) => {
setCurrentMessage(message);
onSend(message);
Expand Down
186 changes: 170 additions & 16 deletions components/Chat/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import { Message, OpenAIModel, OpenAIModelID } from '@/types';
import { Message } from '@/types/chat';
import { OpenAIModel, OpenAIModelID } 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>;
Expand All @@ -23,14 +31,28 @@ 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;
Expand All @@ -47,6 +69,7 @@ export const ChatInput: FC<Props> = ({
}

setContent(value);
updatePromptListVisibility(value);
};

const handleSend = () => {
Expand All @@ -67,6 +90,13 @@ export const ChatInput: FC<Props> = ({
}
};

const handleStopConversation = () => {
stopConversationRef.current = true;
setTimeout(() => {
stopConversationRef.current = false;
}, 1000);
};

const isMobile = () => {
const userAgent =
typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
Expand All @@ -75,15 +105,106 @@ export const ChatInput: FC<Props> = ({
return mobileRegex.test(userAgent);
};

const handleInitModal = () => {
const selectedPrompt = filteredPrompts[activePromptIndex];
setContent((prevContent) => {
const newContent = prevContent?.replace(/\/\w*$/, selectedPrompt.content);
return newContent;
});
handlePromptSelect(selectedPrompt);
setShowPromptList(false);
};

const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (!isTyping) {
if (e.key === 'Enter' && !e.shiftKey && !isMobile()) {
if (showPromptList) {
if (e.key === 'ArrowDown') {
e.preventDefault();
handleSend();
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' && !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';
Expand All @@ -94,19 +215,29 @@ export const ChatInput: FC<Props> = ({
}
}, [content]);

function handleStopConversation() {
stopConversationRef.current = true;
setTimeout(() => {
stopConversationRef.current = false;
}, 1000);
}
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-2 left-0 right-0 mx-auto w-fit rounded border border-neutral-200 bg-white py-2 px-4 text-black dark:border-neutral-600 dark:bg-[#343541] dark:text-white md:top-0"
className="absolute top-2 left-0 right-0 mx-auto mt-2 w-fit 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:top-0"
onClick={handleStopConversation}
>
<IconPlayerStop size={16} className="mb-[2px] inline-block" />{' '}
Expand All @@ -116,18 +247,18 @@ export const ChatInput: FC<Props> = ({

{!messageIsStreaming && !conversationIsEmpty && (
<button
className="absolute -top-2 left-0 right-0 mx-auto w-fit rounded border border-neutral-200 bg-white py-2 px-4 text-black dark:border-neutral-600 dark:bg-[#343541] dark:text-white md:top-0"
className="absolute left-0 right-0 mx-auto mt-2 w-fit 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:top-0"
onClick={onRegenerate}
>
<IconRepeat size={16} className="mb-[2px] inline-block" />{' '}
{t('Regenerate response')}
</button>
)}

<div className="relative flex w-full flex-grow flex-col rounded-md border border-black/10 bg-white py-2 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)] md:py-3 md:pl-4">
<div className="relative mx-2 flex w-full flex-grow flex-col rounded-md border border-black/10 bg-white py-2 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 md:py-3 md:pl-4">
<textarea
ref={textareaRef}
className="m-0 w-full resize-none border-0 bg-transparent p-0 pr-7 pl-2 text-black outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:text-white md:pl-0"
className="m-0 w-full resize-none border-0 bg-transparent p-0 pr-8 pl-2 text-black outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:text-white md:pl-0"
style={{
resize: 'none',
bottom: `${textareaRef?.current?.scrollHeight}px`,
Expand All @@ -138,7 +269,9 @@ export const ChatInput: FC<Props> = ({
: 'hidden'
}`,
}}
placeholder={t('Type a message...') || ''}
placeholder={
t('Type a message or type "/" to select a prompt...') || ''
}
value={content}
rows={1}
onCompositionStart={() => setIsTyping(true)}
Expand All @@ -153,9 +286,30 @@ export const ChatInput: FC<Props> = ({
>
<IconSend size={16} className="opacity-60" />
</button>

{showPromptList && prompts.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-xs text-black/50 dark:text-white/50 md:px-4 md:pt-3 md:pb-6">
<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"
Expand Down
Loading

0 comments on commit 34c79c0

Please sign in to comment.