Skip to content

Commit 34c79c0

Browse files
authored
1 parent 2269403 commit 34c79c0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1743
-294
lines changed

components/Chat/Chat.tsx

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1-
import {
2-
Conversation,
3-
ErrorMessage,
4-
KeyValuePair,
5-
Message,
6-
OpenAIModel,
7-
} from '@/types';
1+
import { Conversation, Message } from '@/types/chat';
2+
import { KeyValuePair } from '@/types/data';
3+
import { ErrorMessage } from '@/types/error';
4+
import { OpenAIModel } from '@/types/openai';
5+
import { Prompt } from '@/types/prompt';
86
import { throttle } from '@/utils';
97
import { IconClearAll, IconKey, IconSettings } from '@tabler/icons-react';
108
import { useTranslation } from 'next-i18next';
11-
import { FC, memo, MutableRefObject, useEffect, useRef, useState } from 'react';
9+
import {
10+
FC,
11+
memo,
12+
MutableRefObject,
13+
useCallback,
14+
useEffect,
15+
useRef,
16+
useState,
17+
} from 'react';
1218
import { Spinner } from '../Global/Spinner';
1319
import { ChatInput } from './ChatInput';
1420
import { ChatLoader } from './ChatLoader';
@@ -24,8 +30,8 @@ interface Props {
2430
serverSideApiKeyIsSet: boolean;
2531
messageIsStreaming: boolean;
2632
modelError: ErrorMessage | null;
27-
messageError: boolean;
2833
loading: boolean;
34+
prompts: Prompt[];
2935
onSend: (message: Message, deleteCount?: number) => void;
3036
onUpdateConversation: (
3137
conversation: Conversation,
@@ -43,8 +49,8 @@ export const Chat: FC<Props> = memo(
4349
serverSideApiKeyIsSet,
4450
messageIsStreaming,
4551
modelError,
46-
messageError,
4752
loading,
53+
prompts,
4854
onSend,
4955
onUpdateConversation,
5056
onEditMessage,
@@ -59,6 +65,27 @@ export const Chat: FC<Props> = memo(
5965
const chatContainerRef = useRef<HTMLDivElement>(null);
6066
const textareaRef = useRef<HTMLTextAreaElement>(null);
6167

68+
const scrollToBottom = useCallback(() => {
69+
if (autoScrollEnabled) {
70+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
71+
textareaRef.current?.focus();
72+
}
73+
}, [autoScrollEnabled]);
74+
75+
const handleScroll = () => {
76+
if (chatContainerRef.current) {
77+
const { scrollTop, scrollHeight, clientHeight } =
78+
chatContainerRef.current;
79+
const bottomTolerance = 30;
80+
81+
if (scrollTop + clientHeight < scrollHeight - bottomTolerance) {
82+
setAutoScrollEnabled(false);
83+
} else {
84+
setAutoScrollEnabled(true);
85+
}
86+
}
87+
};
88+
6289
const handleSettings = () => {
6390
setShowSettings(!showSettings);
6491
};
@@ -174,6 +201,7 @@ export const Chat: FC<Props> = memo(
174201

175202
<SystemPrompt
176203
conversation={conversation}
204+
prompts={prompts}
177205
onChangePrompt={(prompt) =>
178206
onUpdateConversation(conversation, {
179207
key: 'prompt',
@@ -201,8 +229,8 @@ export const Chat: FC<Props> = memo(
201229
/>
202230
</div>
203231
{showSettings && (
204-
<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">
205-
<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">
232+
<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">
233+
<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">
206234
<ModelSelect
207235
model={conversation.model}
208236
models={models}
@@ -241,7 +269,9 @@ export const Chat: FC<Props> = memo(
241269
textareaRef={textareaRef}
242270
messageIsStreaming={messageIsStreaming}
243271
conversationIsEmpty={conversation.messages.length === 0}
272+
messages={conversation.messages}
244273
model={conversation.model}
274+
prompts={prompts}
245275
onSend={(message) => {
246276
setCurrentMessage(message);
247277
onSend(message);

components/Chat/ChatInput.tsx

Lines changed: 170 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
1-
import { Message, OpenAIModel, OpenAIModelID } from '@/types';
1+
import { Message } from '@/types/chat';
2+
import { OpenAIModel, OpenAIModelID } from '@/types/openai';
3+
import { Prompt } from '@/types/prompt';
24
import { IconPlayerStop, IconRepeat, IconSend } from '@tabler/icons-react';
35
import { useTranslation } from 'next-i18next';
46
import {
57
FC,
68
KeyboardEvent,
79
MutableRefObject,
10+
useCallback,
811
useEffect,
12+
useRef,
913
useState,
1014
} from 'react';
15+
import { PromptList } from './PromptList';
16+
import { VariableModal } from './VariableModal';
1117

1218
interface Props {
1319
messageIsStreaming: boolean;
1420
model: OpenAIModel;
1521
conversationIsEmpty: boolean;
22+
messages: Message[];
23+
prompts: Prompt[];
1624
onSend: (message: Message) => void;
1725
onRegenerate: () => void;
1826
stopConversationRef: MutableRefObject<boolean>;
@@ -23,14 +31,28 @@ export const ChatInput: FC<Props> = ({
2331
messageIsStreaming,
2432
model,
2533
conversationIsEmpty,
34+
messages,
35+
prompts,
2636
onSend,
2737
onRegenerate,
2838
stopConversationRef,
2939
textareaRef,
3040
}) => {
3141
const { t } = useTranslation('chat');
42+
3243
const [content, setContent] = useState<string>();
3344
const [isTyping, setIsTyping] = useState<boolean>(false);
45+
const [showPromptList, setShowPromptList] = useState(false);
46+
const [activePromptIndex, setActivePromptIndex] = useState(0);
47+
const [promptInputValue, setPromptInputValue] = useState('');
48+
const [variables, setVariables] = useState<string[]>([]);
49+
const [isModalVisible, setIsModalVisible] = useState(false);
50+
51+
const promptListRef = useRef<HTMLUListElement | null>(null);
52+
53+
const filteredPrompts = prompts.filter((prompt) =>
54+
prompt.name.toLowerCase().includes(promptInputValue.toLowerCase()),
55+
);
3456

3557
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
3658
const value = e.target.value;
@@ -47,6 +69,7 @@ export const ChatInput: FC<Props> = ({
4769
}
4870

4971
setContent(value);
72+
updatePromptListVisibility(value);
5073
};
5174

5275
const handleSend = () => {
@@ -67,6 +90,13 @@ export const ChatInput: FC<Props> = ({
6790
}
6891
};
6992

93+
const handleStopConversation = () => {
94+
stopConversationRef.current = true;
95+
setTimeout(() => {
96+
stopConversationRef.current = false;
97+
}, 1000);
98+
};
99+
70100
const isMobile = () => {
71101
const userAgent =
72102
typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
@@ -75,15 +105,106 @@ export const ChatInput: FC<Props> = ({
75105
return mobileRegex.test(userAgent);
76106
};
77107

108+
const handleInitModal = () => {
109+
const selectedPrompt = filteredPrompts[activePromptIndex];
110+
setContent((prevContent) => {
111+
const newContent = prevContent?.replace(/\/\w*$/, selectedPrompt.content);
112+
return newContent;
113+
});
114+
handlePromptSelect(selectedPrompt);
115+
setShowPromptList(false);
116+
};
117+
78118
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
79-
if (!isTyping) {
80-
if (e.key === 'Enter' && !e.shiftKey && !isMobile()) {
119+
if (showPromptList) {
120+
if (e.key === 'ArrowDown') {
81121
e.preventDefault();
82-
handleSend();
122+
setActivePromptIndex((prevIndex) =>
123+
prevIndex < prompts.length - 1 ? prevIndex + 1 : prevIndex,
124+
);
125+
} else if (e.key === 'ArrowUp') {
126+
e.preventDefault();
127+
setActivePromptIndex((prevIndex) =>
128+
prevIndex > 0 ? prevIndex - 1 : prevIndex,
129+
);
130+
} else if (e.key === 'Tab') {
131+
e.preventDefault();
132+
setActivePromptIndex((prevIndex) =>
133+
prevIndex < prompts.length - 1 ? prevIndex + 1 : 0,
134+
);
135+
} else if (e.key === 'Enter') {
136+
e.preventDefault();
137+
handleInitModal();
138+
} else if (e.key === 'Escape') {
139+
e.preventDefault();
140+
setShowPromptList(false);
141+
} else {
142+
setActivePromptIndex(0);
83143
}
144+
} else if (e.key === 'Enter' && !isMobile() && !e.shiftKey) {
145+
e.preventDefault();
146+
handleSend();
147+
}
148+
};
149+
150+
const parseVariables = (content: string) => {
151+
const regex = /{{(.*?)}}/g;
152+
const foundVariables = [];
153+
let match;
154+
155+
while ((match = regex.exec(content)) !== null) {
156+
foundVariables.push(match[1]);
157+
}
158+
159+
return foundVariables;
160+
};
161+
162+
const updatePromptListVisibility = useCallback((text: string) => {
163+
const match = text.match(/\/\w*$/);
164+
165+
if (match) {
166+
setShowPromptList(true);
167+
setPromptInputValue(match[0].slice(1));
168+
} else {
169+
setShowPromptList(false);
170+
setPromptInputValue('');
171+
}
172+
}, []);
173+
174+
const handlePromptSelect = (prompt: Prompt) => {
175+
const parsedVariables = parseVariables(prompt.content);
176+
setVariables(parsedVariables);
177+
178+
if (parsedVariables.length > 0) {
179+
setIsModalVisible(true);
180+
} else {
181+
setContent((prevContent) => {
182+
const updatedContent = prevContent?.replace(/\/\w*$/, prompt.content);
183+
return updatedContent;
184+
});
185+
updatePromptListVisibility(prompt.content);
186+
}
187+
};
188+
189+
const handleSubmit = (updatedVariables: string[]) => {
190+
const newContent = content?.replace(/{{(.*?)}}/g, (match, variable) => {
191+
const index = variables.indexOf(variable);
192+
return updatedVariables[index];
193+
});
194+
195+
setContent(newContent);
196+
197+
if (textareaRef && textareaRef.current) {
198+
textareaRef.current.focus();
84199
}
85200
};
86201

202+
useEffect(() => {
203+
if (promptListRef.current) {
204+
promptListRef.current.scrollTop = activePromptIndex * 30;
205+
}
206+
}, [activePromptIndex]);
207+
87208
useEffect(() => {
88209
if (textareaRef && textareaRef.current) {
89210
textareaRef.current.style.height = 'inherit';
@@ -94,19 +215,29 @@ export const ChatInput: FC<Props> = ({
94215
}
95216
}, [content]);
96217

97-
function handleStopConversation() {
98-
stopConversationRef.current = true;
99-
setTimeout(() => {
100-
stopConversationRef.current = false;
101-
}, 1000);
102-
}
218+
useEffect(() => {
219+
const handleOutsideClick = (e: MouseEvent) => {
220+
if (
221+
promptListRef.current &&
222+
!promptListRef.current.contains(e.target as Node)
223+
) {
224+
setShowPromptList(false);
225+
}
226+
};
227+
228+
window.addEventListener('click', handleOutsideClick);
229+
230+
return () => {
231+
window.removeEventListener('click', handleOutsideClick);
232+
};
233+
}, []);
103234

104235
return (
105236
<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">
106237
<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">
107238
{messageIsStreaming && (
108239
<button
109-
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"
240+
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"
110241
onClick={handleStopConversation}
111242
>
112243
<IconPlayerStop size={16} className="mb-[2px] inline-block" />{' '}
@@ -116,18 +247,18 @@ export const ChatInput: FC<Props> = ({
116247

117248
{!messageIsStreaming && !conversationIsEmpty && (
118249
<button
119-
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"
250+
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"
120251
onClick={onRegenerate}
121252
>
122253
<IconRepeat size={16} className="mb-[2px] inline-block" />{' '}
123254
{t('Regenerate response')}
124255
</button>
125256
)}
126257

127-
<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">
258+
<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">
128259
<textarea
129260
ref={textareaRef}
130-
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"
261+
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"
131262
style={{
132263
resize: 'none',
133264
bottom: `${textareaRef?.current?.scrollHeight}px`,
@@ -138,7 +269,9 @@ export const ChatInput: FC<Props> = ({
138269
: 'hidden'
139270
}`,
140271
}}
141-
placeholder={t('Type a message...') || ''}
272+
placeholder={
273+
t('Type a message or type "/" to select a prompt...') || ''
274+
}
142275
value={content}
143276
rows={1}
144277
onCompositionStart={() => setIsTyping(true)}
@@ -153,9 +286,30 @@ export const ChatInput: FC<Props> = ({
153286
>
154287
<IconSend size={16} className="opacity-60" />
155288
</button>
289+
290+
{showPromptList && prompts.length > 0 && (
291+
<div className="absolute bottom-12 w-full">
292+
<PromptList
293+
activePromptIndex={activePromptIndex}
294+
prompts={filteredPrompts}
295+
onSelect={handleInitModal}
296+
onMouseOver={setActivePromptIndex}
297+
promptListRef={promptListRef}
298+
/>
299+
</div>
300+
)}
301+
302+
{isModalVisible && (
303+
<VariableModal
304+
prompt={prompts[activePromptIndex]}
305+
variables={variables}
306+
onSubmit={handleSubmit}
307+
onClose={() => setIsModalVisible(false)}
308+
/>
309+
)}
156310
</div>
157311
</div>
158-
<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">
312+
<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">
159313
<a
160314
href="https://github.com/mckaywrigley/chatbot-ui"
161315
target="_blank"

0 commit comments

Comments
 (0)