matt HOFFNER commited on
Commit
b9d9891
β€’
1 Parent(s): c93058d
package-lock.json CHANGED
@@ -10,6 +10,7 @@
10
  "license": "MIT",
11
  "dependencies": {
12
  "@react-llm/headless": "^0.0.5",
 
13
  "@types/node": "20.1.4",
14
  "@types/react": "18.2.6",
15
  "@types/react-dom": "18.2.4",
@@ -20,7 +21,11 @@
20
  "next": "13.4.2",
21
  "react": "18.2.0",
22
  "react-dom": "18.2.0",
23
- "typescript": "5.0.4"
 
 
 
 
24
  }
25
  },
26
  "node_modules/@ampproject/remapping": {
@@ -1319,6 +1324,31 @@
1319
  "tslib": "^2.4.0"
1320
  }
1321
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1322
  "node_modules/@types/estree": {
1323
  "version": "1.0.1",
1324
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz",
@@ -1372,6 +1402,12 @@
1372
  "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
1373
  "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ=="
1374
  },
 
 
 
 
 
 
1375
  "node_modules/@typescript-eslint/parser": {
1376
  "version": "5.59.8",
1377
  "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.8.tgz",
 
10
  "license": "MIT",
11
  "dependencies": {
12
  "@react-llm/headless": "^0.0.5",
13
+ "@tabler/icons-react": "^2.21.0",
14
  "@types/node": "20.1.4",
15
  "@types/react": "18.2.6",
16
  "@types/react-dom": "18.2.4",
 
21
  "next": "13.4.2",
22
  "react": "18.2.0",
23
  "react-dom": "18.2.0",
24
+ "typescript": "5.0.4",
25
+ "uuid": "^9.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/uuid": "^9.0.1"
29
  }
30
  },
31
  "node_modules/@ampproject/remapping": {
 
1324
  "tslib": "^2.4.0"
1325
  }
1326
  },
1327
+ "node_modules/@tabler/icons": {
1328
+ "version": "2.21.0",
1329
+ "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-2.21.0.tgz",
1330
+ "integrity": "sha512-XKrTEHMX6XzCOwcOU8ZNA+Xqm51sI+0abn2jk1fyQUpWeFnGsOEiC+fpQ4EISc+v+U9jqgTSbh8bZ6JBuKU5sw==",
1331
+ "funding": {
1332
+ "type": "github",
1333
+ "url": "https://github.com/sponsors/codecalm"
1334
+ }
1335
+ },
1336
+ "node_modules/@tabler/icons-react": {
1337
+ "version": "2.21.0",
1338
+ "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-2.21.0.tgz",
1339
+ "integrity": "sha512-iW/Eqqb7gILb8w/BgzCLZ/8DFZxojgiu12BYJFVuWIblLysXTZ2FjahY7m0VQ41vsyfDFWKofn1uvIPCfa8yGQ==",
1340
+ "dependencies": {
1341
+ "@tabler/icons": "2.21.0",
1342
+ "prop-types": "^15.7.2"
1343
+ },
1344
+ "funding": {
1345
+ "type": "github",
1346
+ "url": "https://github.com/sponsors/codecalm"
1347
+ },
1348
+ "peerDependencies": {
1349
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0"
1350
+ }
1351
+ },
1352
  "node_modules/@types/estree": {
1353
  "version": "1.0.1",
1354
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz",
 
1402
  "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
1403
  "integrity": "sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ=="
1404
  },
1405
+ "node_modules/@types/uuid": {
1406
+ "version": "9.0.1",
1407
+ "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz",
1408
+ "integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==",
1409
+ "dev": true
1410
+ },
1411
  "node_modules/@typescript-eslint/parser": {
1412
  "version": "5.59.8",
1413
  "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.8.tgz",
package.json CHANGED
@@ -10,6 +10,7 @@
10
  },
11
  "dependencies": {
12
  "@react-llm/headless": "^0.0.5",
 
13
  "@types/node": "20.1.4",
14
  "@types/react": "18.2.6",
15
  "@types/react-dom": "18.2.4",
@@ -20,6 +21,10 @@
20
  "next": "13.4.2",
21
  "react": "18.2.0",
22
  "react-dom": "18.2.0",
23
- "typescript": "5.0.4"
 
 
 
 
24
  }
25
  }
 
10
  },
11
  "dependencies": {
12
  "@react-llm/headless": "^0.0.5",
13
+ "@tabler/icons-react": "^2.21.0",
14
  "@types/node": "20.1.4",
15
  "@types/react": "18.2.6",
16
  "@types/react-dom": "18.2.4",
 
21
  "next": "13.4.2",
22
  "react": "18.2.0",
23
  "react-dom": "18.2.0",
24
+ "typescript": "5.0.4",
25
+ "uuid": "^9.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/uuid": "^9.0.1"
29
  }
30
  }
src/components/Chat/Chat.tsx ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IconClearAll, IconSettings } from '@tabler/icons-react';
2
+ import {
3
+ MutableRefObject,
4
+ memo,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+
12
+ import {
13
+ saveConversation,
14
+ saveConversations,
15
+ updateConversation,
16
+ throttle
17
+ } from '@/utils';
18
+
19
+ import { ChatBody, Conversation, Message } from '@/types/chat';
20
+
21
+ import HomeContext from '@/pages/api/home.context';
22
+
23
+ import { ChatInput } from './ChatInput';
24
+ import { ChatLoader } from './ChatLoader';
25
+
26
+ interface Props {
27
+ stopConversationRef: MutableRefObject<boolean>;
28
+ }
29
+
30
+ export const Chat = memo(({ stopConversationRef }: Props) => {
31
+ const {
32
+ state: {
33
+ selectedConversation,
34
+ conversations,
35
+ loading
36
+ },
37
+ dispatch: homeDispatch,
38
+ }: any = useContext(HomeContext);
39
+
40
+ const [currentMessage, setCurrentMessage] = useState<Message>();
41
+ const [autoScrollEnabled, setAutoScrollEnabled] = useState<boolean>(true);
42
+ const [showSettings, setShowSettings] = useState<boolean>(false);
43
+ const [showScrollDownButton, setShowScrollDownButton] =
44
+ useState<boolean>(false);
45
+
46
+ const messagesEndRef = useRef<HTMLDivElement>(null);
47
+ const chatContainerRef = useRef<HTMLDivElement>(null);
48
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
49
+
50
+ const handleSend = useCallback(
51
+ async (message: Message, deleteCount = 0, plugin: Plugin | null = null) => {
52
+ if (selectedConversation) {
53
+ let updatedConversation: Conversation;
54
+ if (deleteCount) {
55
+ const updatedMessages = [...selectedConversation.messages];
56
+ for (let i = 0; i < deleteCount; i++) {
57
+ updatedMessages.pop();
58
+ }
59
+ updatedConversation = {
60
+ ...selectedConversation,
61
+ messages: [...updatedMessages, message],
62
+ };
63
+ } else {
64
+ updatedConversation = {
65
+ ...selectedConversation,
66
+ messages: [...selectedConversation.messages, message],
67
+ };
68
+ }
69
+ homeDispatch({
70
+ field: 'selectedConversation',
71
+ value: updatedConversation,
72
+ });
73
+ homeDispatch({ field: 'loading', value: true });
74
+ homeDispatch({ field: 'messageIsStreaming', value: true });
75
+ const chatBody: ChatBody = {
76
+ model: updatedConversation.model,
77
+ messages: updatedConversation.messages,
78
+ prompt: updatedConversation.prompt
79
+ };
80
+ const endpoint = "/v1/api/create"
81
+ const body = JSON.stringify(chatBody);
82
+ const controller = new AbortController();
83
+ const response = await fetch(endpoint, {
84
+ method: 'POST',
85
+ headers: {
86
+ 'Content-Type': 'application/json',
87
+ },
88
+ signal: controller.signal,
89
+ body,
90
+ });
91
+ if (!response.ok) {
92
+ homeDispatch({ field: 'loading', value: false });
93
+ homeDispatch({ field: 'messageIsStreaming', value: false });
94
+ console.error(response.statusText);
95
+ return;
96
+ }
97
+ const data = response.body;
98
+ if (!data) {
99
+ homeDispatch({ field: 'loading', value: false });
100
+ homeDispatch({ field: 'messageIsStreaming', value: false });
101
+ return;
102
+ }
103
+ if (!plugin) {
104
+ if (updatedConversation.messages.length === 1) {
105
+ const { content } = message;
106
+ const customName =
107
+ content.length > 30 ? content.substring(0, 30) + '...' : content;
108
+ updatedConversation = {
109
+ ...updatedConversation,
110
+ name: customName,
111
+ };
112
+ }
113
+ homeDispatch({ field: 'loading', value: false });
114
+ const reader = data.getReader();
115
+ const decoder = new TextDecoder();
116
+ let done = false;
117
+ let isFirst = true;
118
+ let text = '';
119
+ while (!done) {
120
+ if (stopConversationRef.current === true) {
121
+ controller.abort();
122
+ done = true;
123
+ break;
124
+ }
125
+ const { value, done: doneReading } = await reader.read();
126
+ done = doneReading;
127
+ const chunkValue = decoder.decode(value);
128
+ text += chunkValue;
129
+ if (isFirst) {
130
+ isFirst = false;
131
+ const updatedMessages: Message[] = [
132
+ ...updatedConversation.messages,
133
+ { role: 'assistant', content: chunkValue },
134
+ ];
135
+ updatedConversation = {
136
+ ...updatedConversation,
137
+ messages: updatedMessages,
138
+ };
139
+ homeDispatch({
140
+ field: 'selectedConversation',
141
+ value: updatedConversation,
142
+ });
143
+ } else {
144
+ const updatedMessages: Message[] =
145
+ updatedConversation.messages.map((message: any, index: number) => {
146
+ if (index === updatedConversation.messages.length - 1) {
147
+ return {
148
+ ...message,
149
+ content: text,
150
+ };
151
+ }
152
+ return message;
153
+ });
154
+ updatedConversation = {
155
+ ...updatedConversation,
156
+ messages: updatedMessages,
157
+ };
158
+ homeDispatch({
159
+ field: 'selectedConversation',
160
+ value: updatedConversation,
161
+ });
162
+ }
163
+ }
164
+ saveConversation(updatedConversation);
165
+ const updatedConversations: Conversation[] = conversations.map(
166
+ (conversation: { id: any; }) => {
167
+ if (conversation.id === selectedConversation.id) {
168
+ return updatedConversation;
169
+ }
170
+ return conversation;
171
+ },
172
+ );
173
+ if (updatedConversations.length === 0) {
174
+ updatedConversations.push(updatedConversation);
175
+ }
176
+ homeDispatch({ field: 'conversations', value: updatedConversations });
177
+ saveConversations(updatedConversations);
178
+ homeDispatch({ field: 'messageIsStreaming', value: false });
179
+ } else {
180
+ const { answer } = await response.json();
181
+ const updatedMessages: Message[] = [
182
+ ...updatedConversation.messages,
183
+ { role: 'assistant', content: answer },
184
+ ];
185
+ updatedConversation = {
186
+ ...updatedConversation,
187
+ messages: updatedMessages,
188
+ };
189
+ homeDispatch({
190
+ field: 'selectedConversation',
191
+ value: updateConversation,
192
+ });
193
+ saveConversation(updatedConversation);
194
+ const updatedConversations: Conversation[] = conversations.map(
195
+ (conversation: { id: any; }) => {
196
+ if (conversation.id === selectedConversation.id) {
197
+ return updatedConversation;
198
+ }
199
+ return conversation;
200
+ },
201
+ );
202
+ if (updatedConversations.length === 0) {
203
+ updatedConversations.push(updatedConversation);
204
+ }
205
+ homeDispatch({ field: 'conversations', value: updatedConversations });
206
+ saveConversations(updatedConversations);
207
+ homeDispatch({ field: 'loading', value: false });
208
+ homeDispatch({ field: 'messageIsStreaming', value: false });
209
+ }
210
+ }
211
+ },
212
+ [
213
+ conversations,
214
+ selectedConversation,
215
+ stopConversationRef,
216
+ ],
217
+ );
218
+
219
+ const handleScroll = () => {
220
+ if (chatContainerRef.current) {
221
+ const { scrollTop, scrollHeight, clientHeight } =
222
+ chatContainerRef.current;
223
+ const bottomTolerance = 30;
224
+
225
+ if (scrollTop + clientHeight < scrollHeight - bottomTolerance) {
226
+ setAutoScrollEnabled(false);
227
+ setShowScrollDownButton(true);
228
+ } else {
229
+ setAutoScrollEnabled(true);
230
+ setShowScrollDownButton(false);
231
+ }
232
+ }
233
+ };
234
+
235
+ const handleScrollDown = () => {
236
+ chatContainerRef.current?.scrollTo({
237
+ top: chatContainerRef.current.scrollHeight,
238
+ behavior: 'smooth',
239
+ });
240
+ };
241
+
242
+ const handleSettings = () => {
243
+ setShowSettings(!showSettings);
244
+ };
245
+
246
+ const onClearAll = () => {
247
+ if (
248
+ confirm(t<string>('Are you sure you want to clear all messages?')) &&
249
+ selectedConversation
250
+ ) {
251
+ handleUpdateConversation(selectedConversation, {
252
+ key: 'messages',
253
+ value: [],
254
+ });
255
+ }
256
+ };
257
+
258
+ const scrollDown = () => {
259
+ if (autoScrollEnabled) {
260
+ messagesEndRef.current?.scrollIntoView(true);
261
+ }
262
+ };
263
+ const throttledScrollDown = throttle(scrollDown, 250);
264
+
265
+ useEffect(() => {
266
+ throttledScrollDown();
267
+ selectedConversation &&
268
+ setCurrentMessage(
269
+ selectedConversation.messages[selectedConversation.messages.length - 2],
270
+ );
271
+ }, [selectedConversation, throttledScrollDown]);
272
+
273
+ useEffect(() => {
274
+ const observer = new IntersectionObserver(
275
+ ([entry]) => {
276
+ setAutoScrollEnabled(entry.isIntersecting);
277
+ if (entry.isIntersecting) {
278
+ textareaRef.current?.focus();
279
+ }
280
+ },
281
+ {
282
+ root: null,
283
+ threshold: 0.5,
284
+ },
285
+ );
286
+ const messagesEndElement = messagesEndRef.current;
287
+ if (messagesEndElement) {
288
+ observer.observe(messagesEndElement);
289
+ }
290
+ return () => {
291
+ if (messagesEndElement) {
292
+ observer.unobserve(messagesEndElement);
293
+ }
294
+ };
295
+ }, [messagesEndRef]);
296
+
297
+ return (
298
+ <div className="relative flex-1 overflow-hidden bg-white dark:bg-[#343541]">
299
+ <div
300
+ className="max-h-full overflow-x-hidden"
301
+ ref={chatContainerRef}
302
+ onScroll={handleScroll}
303
+ >
304
+ {selectedConversation?.messages.length === 0 ? (
305
+ <>
306
+ <div className="mx-auto flex flex-col space-y-5 md:space-y-10 px-3 pt-5 md:pt-12 sm:max-w-[600px]">
307
+
308
+
309
+ </div>
310
+ </>
311
+ ) : (
312
+ <>
313
+ <div className="sticky top-0 z-10 flex justify-center border border-b-neutral-300 bg-neutral-100 py-2 text-sm text-neutral-500 dark:border-none dark:bg-[#444654] dark:text-neutral-200">
314
+ <button
315
+ className="ml-2 cursor-pointer hover:opacity-50"
316
+ onClick={handleSettings}
317
+ >
318
+ <IconSettings size={18} />
319
+ </button>
320
+ <button
321
+ className="ml-2 cursor-pointer hover:opacity-50"
322
+ onClick={onClearAll}
323
+ >
324
+ <IconClearAll size={18} />
325
+ </button>
326
+ </div>
327
+ {loading && <ChatLoader />}
328
+ <div
329
+ className="h-[162px] bg-white dark:bg-[#343541]"
330
+ ref={messagesEndRef}
331
+ />
332
+ </>
333
+ )}
334
+ </div>
335
+
336
+ <ChatInput
337
+ stopConversationRef={stopConversationRef}
338
+ textareaRef={textareaRef}
339
+ onSend={(message: any, plugin: any) => {
340
+ setCurrentMessage(message);
341
+ handleSend(message, 0, plugin);
342
+ }}
343
+ onScrollDownClick={handleScrollDown}
344
+ onRegenerate={() => {
345
+ if (currentMessage) {
346
+ handleSend(currentMessage, 2, null);
347
+ }
348
+ }}
349
+ showScrollDownButton={showScrollDownButton}
350
+ />
351
+ </div>
352
+ );
353
+ });
354
+ Chat.displayName = 'Chat';
src/components/Chat/ChatInput.tsx ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ IconPlayerStop,
3
+ IconRepeat,
4
+ IconSend,
5
+ } from '@tabler/icons-react';
6
+ import {
7
+ KeyboardEvent,
8
+ MutableRefObject,
9
+ useCallback,
10
+ useContext,
11
+ useEffect,
12
+ useRef,
13
+ useState,
14
+ } from 'react';
15
+
16
+ import { Message } from '@/types/chat';
17
+ import { Prompt } from '@/types/prompt';
18
+
19
+ import HomeContext from '@/pages/api/home.context';
20
+
21
+ interface Props {
22
+ onSend: (message: Message) => void;
23
+ onRegenerate: () => void;
24
+ onScrollDownClick: () => void;
25
+ stopConversationRef: MutableRefObject<boolean>;
26
+ textareaRef: MutableRefObject<HTMLTextAreaElement | null>;
27
+ showScrollDownButton: boolean;
28
+ }
29
+
30
+ export const ChatInput = ({
31
+ onSend,
32
+ onRegenerate,
33
+ stopConversationRef,
34
+ textareaRef,
35
+ }: Props) => {
36
+
37
+ const {
38
+ state: { selectedConversation, messageIsStreaming, prompts },
39
+
40
+ dispatch: homeDispatch,
41
+ }: any = useContext(HomeContext);
42
+
43
+ const [content, setContent] = useState<string>();
44
+ 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
+
50
+ const promptListRef = useRef<HTMLUListElement | null>(null);
51
+
52
+ const filteredPrompts = prompts.filter((prompt: { name: string; }) =>
53
+ prompt.name.toLowerCase().includes(promptInputValue.toLowerCase()),
54
+ );
55
+
56
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
57
+ const value = e.target.value;
58
+ const maxLength = selectedConversation?.model.maxLength;
59
+
60
+ if (maxLength && value.length > maxLength) {
61
+ alert(
62
+ `Message limit is ${maxLength} characters. You have entered ${value.length} characters.`,
63
+ );
64
+ return;
65
+ }
66
+
67
+ setContent(value);
68
+ updatePromptListVisibility(value);
69
+ };
70
+
71
+ const handleSend = () => {
72
+ if (messageIsStreaming) {
73
+ return;
74
+ }
75
+
76
+ if (!content) {
77
+ alert('Please enter a message');
78
+ return;
79
+ }
80
+
81
+ onSend({ role: 'user', content });
82
+ setContent('');
83
+
84
+ if (window.innerWidth < 640 && textareaRef && textareaRef.current) {
85
+ textareaRef.current.blur();
86
+ }
87
+ };
88
+
89
+ const handleStopConversation = () => {
90
+ stopConversationRef.current = true;
91
+ setTimeout(() => {
92
+ stopConversationRef.current = false;
93
+ }, 1000);
94
+ };
95
+
96
+ const isMobile = () => {
97
+ const userAgent =
98
+ typeof window.navigator === 'undefined' ? '' : navigator.userAgent;
99
+ const mobileRegex =
100
+ /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i;
101
+ return mobileRegex.test(userAgent);
102
+ };
103
+
104
+ const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
105
+ if (e.key === 'Enter' && !isTyping && !isMobile() && !e.shiftKey) {
106
+ e.preventDefault();
107
+ handleSend();
108
+ } else if (e.key === '/' && e.metaKey) {
109
+ e.preventDefault();
110
+
111
+ }
112
+ };
113
+
114
+ const parseVariables = (content: string) => {
115
+ const regex = /{{(.*?)}}/g;
116
+ const foundVariables = [];
117
+ let match;
118
+
119
+ while ((match = regex.exec(content)) !== null) {
120
+ foundVariables.push(match[1]);
121
+ }
122
+
123
+ return foundVariables;
124
+ };
125
+
126
+ const updatePromptListVisibility = useCallback((text: string) => {
127
+ const match = text.match(/\/\w*$/);
128
+
129
+ if (match) {
130
+ setShowPromptList(true);
131
+ setPromptInputValue(match[0].slice(1));
132
+ } else {
133
+ setShowPromptList(false);
134
+ setPromptInputValue('');
135
+ }
136
+ }, []);
137
+
138
+ const handlePromptSelect = (prompt: Prompt) => {
139
+ const parsedVariables = parseVariables(prompt.content);
140
+ setVariables(parsedVariables);
141
+
142
+ if (parsedVariables.length > 0) {
143
+ setIsModalVisible(true);
144
+ } else {
145
+ setContent((prevContent) => {
146
+ const updatedContent = prevContent?.replace(/\/\w*$/, prompt.content);
147
+ return updatedContent;
148
+ });
149
+ updatePromptListVisibility(prompt.content);
150
+ }
151
+ };
152
+
153
+ const handleSubmit = (updatedVariables: string[]) => {
154
+ const newContent = content?.replace(/{{(.*?)}}/g, (match, variable) => {
155
+ const index = variables.indexOf(variable);
156
+ return updatedVariables[index];
157
+ });
158
+
159
+ setContent(newContent);
160
+
161
+ if (textareaRef && textareaRef.current) {
162
+ textareaRef.current.focus();
163
+ }
164
+ };
165
+
166
+ useEffect(() => {
167
+ if (promptListRef.current) {
168
+ promptListRef.current.scrollTop = activePromptIndex * 30;
169
+ }
170
+ }, [activePromptIndex]);
171
+
172
+ useEffect(() => {
173
+ if (textareaRef && textareaRef.current) {
174
+ textareaRef.current.style.height = 'inherit';
175
+ textareaRef.current.style.height = `${textareaRef.current?.scrollHeight}px`;
176
+ textareaRef.current.style.overflow = `${
177
+ textareaRef?.current?.scrollHeight > 400 ? 'auto' : 'hidden'
178
+ }`;
179
+ }
180
+ }, [content]);
181
+
182
+ useEffect(() => {
183
+ const handleOutsideClick = (e: MouseEvent) => {
184
+ if (
185
+ promptListRef.current &&
186
+ !promptListRef.current.contains(e.target as Node)
187
+ ) {
188
+ setShowPromptList(false);
189
+ }
190
+ };
191
+
192
+ window.addEventListener('click', handleOutsideClick);
193
+
194
+ return () => {
195
+ window.removeEventListener('click', handleOutsideClick);
196
+ };
197
+ }, []);
198
+
199
+ return (
200
+ <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">
201
+ <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">
202
+ {messageIsStreaming && (
203
+ <button
204
+ 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"
205
+ onClick={handleStopConversation}
206
+ >
207
+ <IconPlayerStop size={16} /> {'Stop Generating'}
208
+ </button>
209
+ )}
210
+
211
+ {!messageIsStreaming &&
212
+ selectedConversation &&
213
+ selectedConversation.messages.length > 0 && (
214
+ <button
215
+ 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"
216
+ onClick={onRegenerate}
217
+ >
218
+ <IconRepeat size={16} /> {'Regenerate response'}
219
+ </button>
220
+ )}
221
+
222
+ <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">
223
+
224
+ <textarea
225
+ ref={textareaRef}
226
+ 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"
227
+ style={{
228
+ resize: 'none',
229
+ bottom: `${textareaRef?.current?.scrollHeight}px`,
230
+ maxHeight: '400px',
231
+ overflow: `${
232
+ textareaRef.current && textareaRef.current.scrollHeight > 400
233
+ ? 'auto'
234
+ : 'hidden'
235
+ }`,
236
+ }}
237
+ placeholder={
238
+ 'Type a message or type "/" to select a prompt...' || ''
239
+ }
240
+ value={content}
241
+ rows={1}
242
+ onCompositionStart={() => setIsTyping(true)}
243
+ onCompositionEnd={() => setIsTyping(false)}
244
+ onChange={handleChange}
245
+ onKeyDown={handleKeyDown}
246
+ />
247
+
248
+ <button
249
+ 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"
250
+ onClick={handleSend}
251
+ >
252
+ {messageIsStreaming ? (
253
+ <div className="h-4 w-4 animate-spin rounded-full border-t-2 border-neutral-800 opacity-60 dark:border-neutral-100"></div>
254
+ ) : (
255
+ <IconSend size={18} />
256
+ )}
257
+ </button>
258
+ </div>
259
+ </div>
260
+ <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">
261
+
262
+ </div>
263
+ </div>
264
+ );
265
+ };
src/components/Chat/ChatLoader.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IconRobot } from '@tabler/icons-react';
2
+ import { FC } from 'react';
3
+
4
+ interface Props { }
5
+
6
+ export const ChatLoader: FC<Props> = () => {
7
+ return (
8
+ <div
9
+ className="group border-b border-black/10 bg-gray-50 text-gray-800 dark:border-gray-900/50 dark:bg-[#444654] dark:text-gray-100"
10
+ style={{ overflowWrap: 'anywhere' }}
11
+ >
12
+ <div className="m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
13
+ <div className="min-w-[40px] items-end">
14
+ <IconRobot size={30} />
15
+ </div>
16
+ <span className="animate-pulse cursor-default mt-1">▍</span>
17
+ </div>
18
+ </div>
19
+ );
20
+ };
src/pages/api/home.context.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Dispatch, createContext } from 'react';
2
+
3
+ import { ActionType } from '@/utils/';
4
+
5
+ import { Conversation } from '@/types/chat';
6
+ import { KeyValuePair } from '@/types/data';
7
+
8
+ import { HomeInitialState } from './home.state';
9
+
10
+ export interface HomeContextProps {
11
+ state: HomeInitialState;
12
+ dispatch: Dispatch<ActionType<HomeInitialState>>;
13
+ handleNewConversation: () => void;
14
+ handleSelectConversation: (conversation: Conversation) => void;
15
+ handleUpdateConversation: (
16
+ conversation: Conversation,
17
+ data: KeyValuePair,
18
+ ) => void;
19
+ }
20
+
21
+ const HomeContext = createContext<HomeContextProps>(undefined!);
22
+
23
+ export default HomeContext;
src/pages/api/home.state.tsx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Conversation, Message } from '@/types/chat';
2
+ import { Prompt } from '@/types/prompt';
3
+
4
+ export interface HomeInitialState {
5
+ loading: boolean;
6
+ lightMode: 'light' | 'dark';
7
+ messageIsStreaming: boolean;
8
+ modelError: any | null;
9
+ models: any[];
10
+ conversations: Conversation[];
11
+ selectedConversation: Conversation | undefined;
12
+ currentMessage: Message | undefined;
13
+ prompts: Prompt[];
14
+ temperature: number;
15
+ showChatbar: boolean;
16
+ showPromptbar: boolean;
17
+ messageError: boolean;
18
+ searchTerm: string;
19
+ defaultModelId: any | undefined;
20
+ serverSideApiKeyIsSet: boolean;
21
+ serverSidePluginKeysSet: boolean;
22
+ }
23
+
24
+ export const initialState: HomeInitialState = {
25
+ loading: false,
26
+ lightMode: 'dark',
27
+ messageIsStreaming: false,
28
+ modelError: null,
29
+ models: [],
30
+ conversations: [],
31
+ selectedConversation: undefined,
32
+ currentMessage: undefined,
33
+ prompts: [],
34
+ temperature: 1,
35
+ showPromptbar: true,
36
+ showChatbar: true,
37
+ messageError: false,
38
+ searchTerm: '',
39
+ defaultModelId: undefined,
40
+ serverSideApiKeyIsSet: false,
41
+ serverSidePluginKeysSet: false,
42
+ };
src/pages/api/home.tsx ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ import { GetServerSideProps } from 'next';
4
+ import Head from 'next/head';
5
+ import {
6
+ DEFAULT_SYSTEM_PROMPT,
7
+ DEFAULT_TEMPERATURE,
8
+ saveConversation,
9
+ saveConversations,
10
+ updateConversation,
11
+ useCreateReducer
12
+ } from '@/utils';
13
+
14
+ import { Chat } from '@/components/Chat/Chat';
15
+
16
+ import HomeContext from './home.context';
17
+ import { HomeInitialState, initialState } from './home.state';
18
+
19
+ import { v4 as uuidv4 } from 'uuid';
20
+
21
+ interface Props {
22
+ serverSideApiKeyIsSet: boolean;
23
+ serverSidePluginKeysSet: boolean;
24
+ defaultModelId: any;
25
+ }
26
+
27
+ const Home = ({
28
+ defaultModelId,
29
+ }: Props) => {
30
+ const contextValue = useCreateReducer<HomeInitialState>({
31
+ initialState,
32
+ });
33
+
34
+ const {
35
+ state: {
36
+ lightMode,
37
+ conversations,
38
+ selectedConversation
39
+ },
40
+ dispatch,
41
+ } = contextValue;
42
+
43
+ const stopConversationRef = useRef<boolean>(false);
44
+
45
+ // FETCH MODELS ----------------------------------------------
46
+
47
+ const handleSelectConversation = (conversation: any) => {
48
+ dispatch({
49
+ field: 'selectedConversation',
50
+ value: conversation,
51
+ });
52
+
53
+ saveConversation(conversation);
54
+ };
55
+
56
+ // CONVERSATION OPERATIONS --------------------------------------------
57
+
58
+ const handleNewConversation = () => {
59
+ const lastConversation = conversations[conversations.length - 1];
60
+
61
+ const newConversation: any = {
62
+ id: uuidv4(),
63
+ name: 'New Conversation',
64
+ messages: [],
65
+ model: lastConversation?.model || {
66
+ id: "OpenAIModels[defaultModelId].id",
67
+ name: "OpenAIModels[defaultModelId].name",
68
+ maxLength: "OpenAIModels[defaultModelId].maxLength",
69
+ tokenLimit: "OpenAIModels[defaultModelId].tokenLimit",
70
+ },
71
+ prompt: DEFAULT_SYSTEM_PROMPT,
72
+ temperature: lastConversation?.temperature ?? DEFAULT_TEMPERATURE,
73
+ folderId: null,
74
+ };
75
+
76
+ const updatedConversations = [...conversations, newConversation];
77
+
78
+ dispatch({ field: 'selectedConversation', value: newConversation });
79
+ dispatch({ field: 'conversations', value: updatedConversations });
80
+
81
+ saveConversation(newConversation);
82
+ saveConversations(updatedConversations);
83
+
84
+ dispatch({ field: 'loading', value: false });
85
+ };
86
+
87
+ const handleUpdateConversation = (
88
+ conversation: any,
89
+ data: any,
90
+ ) => {
91
+ const updatedConversation = {
92
+ ...conversation,
93
+ [data.key]: data.value,
94
+ };
95
+
96
+ const { single, all } = updateConversation(
97
+ updatedConversation,
98
+ conversations,
99
+ );
100
+
101
+ dispatch({ field: 'selectedConversation', value: single });
102
+ dispatch({ field: 'conversations', value: all });
103
+ };
104
+
105
+ // EFFECTS --------------------------------------------
106
+
107
+ useEffect(() => {
108
+ if (window.innerWidth < 640) {
109
+ dispatch({ field: 'showChatbar', value: false });
110
+ }
111
+ }, [selectedConversation]);
112
+
113
+ // ON LOAD --------------------------------------------
114
+
115
+ return (
116
+ <HomeContext.Provider
117
+ value={{
118
+ ...contextValue,
119
+ handleNewConversation,
120
+ handleSelectConversation,
121
+ handleUpdateConversation,
122
+ }}
123
+ >
124
+ <Head>
125
+ <title>Web LLM Embed</title>
126
+ <meta name="description" content="Web LLM Embed" />
127
+ <meta
128
+ name="viewport"
129
+ content="height=device-height ,width=device-width, initial-scale=1, user-scalable=no"
130
+ />
131
+ <link rel="icon" href="/favicon.ico" />
132
+ </Head>
133
+ <main
134
+ className={`flex h-screen w-screen flex-col text-sm text-white dark:text-white ${lightMode}`}
135
+ >
136
+ <div className="flex h-full w-full pt-[48px] sm:pt-0">
137
+ <div className="flex flex-1">
138
+ <Chat stopConversationRef={stopConversationRef} />
139
+ </div>
140
+ </div>
141
+ </main>
142
+ </HomeContext.Provider>
143
+ );
144
+ };
145
+ export default Home;
146
+
147
+ export const getServerSideProps: GetServerSideProps = async ({ locale }) => {
148
+ const defaultModelId = "fallbackModelID"
149
+
150
+ return {
151
+ props: {
152
+ defaultModelId
153
+ },
154
+ };
155
+ };
156
+
157
+
src/pages/index.tsx CHANGED
@@ -1,13 +1 @@
1
- import Head from "next/head";
2
-
3
- export default function Home() {
4
- return (
5
- <>
6
- <Head>
7
- <title>web-llm-embed</title>
8
- <meta name="description" content="Web LLM Embed" />
9
- </Head>
10
- <div>web-llm-embed</div>
11
- </>
12
- );
13
- }
 
1
+ export { default, getServerSideProps } from './api/home';
 
 
 
 
 
 
 
 
 
 
 
 
src/prompt/index.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ export const QA_PROMPT = `
2
+ Your name is Ronnie C.
3
+ Use the following pieces of context to answer the users question.
4
+ If you don't know the answer, just say that you don't know, don't try to make up an answer.
5
+ Always answer from the perspective of being Ronnie C.
6
+ ----------------
7
+ {context}
8
+
9
+ Question: {question}
10
+ Helpful Answer:`
src/types/chat.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface Message {
2
+ role: Role;
3
+ content: string;
4
+ }
5
+
6
+ export type Role = 'assistant' | 'user';
7
+
8
+ export interface ChatBody {
9
+ model: any;
10
+ messages: Message[];
11
+ key: string;
12
+ prompt: string;
13
+ temperature: number;
14
+ }
15
+
16
+ export interface Conversation {
17
+ id: string;
18
+ name: string;
19
+ messages: Message[];
20
+ model: any;
21
+ prompt: string;
22
+ temperature: number;
23
+ folderId: string | null;
24
+ }
25
+
src/types/data.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export interface KeyValuePair {
2
+ key: string;
3
+ value: any;
4
+ }
5
+
src/types/prompt.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface Prompt {
2
+ id: string;
3
+ name: string;
4
+ description: string;
5
+ content: string;
6
+ model: any;
7
+ folderId: string | null;
8
+ }
9
+
10
+
src/utils/index.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Conversation } from '@/types/chat';
2
+ import { useMemo, useReducer } from 'react';
3
+
4
+ // Extracts property names from initial state of reducer to allow typesafe dispatch objects
5
+ export type FieldNames<T> = {
6
+ [K in keyof T]: T[K] extends string ? K : K;
7
+ }[keyof T];
8
+
9
+ // Returns the Action Type for the dispatch object to be used for typing in things like context
10
+ export type ActionType<T> =
11
+ | { type: 'reset' }
12
+ | { type?: 'change'; field: FieldNames<T>; value: any };
13
+
14
+ // Returns a typed dispatch and state
15
+ export const useCreateReducer = <T>({ initialState }: { initialState: T }) => {
16
+ type Action =
17
+ | { type: 'reset' }
18
+ | { type?: 'change'; field: FieldNames<T>; value: any };
19
+
20
+ const reducer = (state: T, action: Action) => {
21
+ if (!action.type) return { ...state, [action.field]: action.value };
22
+
23
+ if (action.type === 'reset') return initialState;
24
+
25
+ throw new Error();
26
+ };
27
+
28
+ const [state, dispatch] = useReducer(reducer, initialState);
29
+
30
+ return useMemo(() => ({ state, dispatch }), [state, dispatch]);
31
+ };
32
+
33
+ export const updateConversation = (
34
+ updatedConversation: Conversation,
35
+ allConversations: Conversation[],
36
+ ) => {
37
+ const updatedConversations = allConversations.map((c) => {
38
+ if (c.id === updatedConversation.id) {
39
+ return updatedConversation;
40
+ }
41
+
42
+ return c;
43
+ });
44
+
45
+ saveConversation(updatedConversation);
46
+ saveConversations(updatedConversations);
47
+
48
+ return {
49
+ single: updatedConversation,
50
+ all: updatedConversations,
51
+ };
52
+ };
53
+
54
+ export const saveConversation = (conversation: Conversation) => {
55
+ localStorage.setItem('selectedConversation', JSON.stringify(conversation));
56
+ };
57
+
58
+ export const saveConversations = (conversations: Conversation[]) => {
59
+ localStorage.setItem('conversationHistory', JSON.stringify(conversations));
60
+ };
61
+
62
+
63
+ export function throttle<T extends (...args: any[]) => any>(
64
+ func: T,
65
+ limit: number,
66
+ ): T {
67
+ let lastFunc: ReturnType<typeof setTimeout>;
68
+ let lastRan: number;
69
+
70
+ return ((...args) => {
71
+ if (!lastRan) {
72
+ func(...args);
73
+ lastRan = Date.now();
74
+ } else {
75
+ clearTimeout(lastFunc);
76
+ lastFunc = setTimeout(() => {
77
+ if (Date.now() - lastRan >= limit) {
78
+ func(...args);
79
+ lastRan = Date.now();
80
+ }
81
+ }, limit - (Date.now() - lastRan));
82
+ }
83
+ }) as T;
84
+ }
85
+
86
+ export const DEFAULT_SYSTEM_PROMPT =
87
+ process.env.NEXT_PUBLIC_DEFAULT_SYSTEM_PROMPT ||
88
+ "Follow the user's instructions carefully. Respond using markdown.";
89
+
90
+ export const DEFAULT_TEMPERATURE =
91
+ parseFloat(process.env.NEXT_PUBLIC_DEFAULT_TEMPERATURE || "1");