Spaces:
Runtime error
Runtime error
matt HOFFNER
commited on
Commit
Β·
b9d9891
1
Parent(s):
c93058d
barebones
Browse files- package-lock.json +37 -1
- package.json +6 -1
- src/components/Chat/Chat.tsx +354 -0
- src/components/Chat/ChatInput.tsx +265 -0
- src/components/Chat/ChatLoader.tsx +20 -0
- src/pages/api/home.context.tsx +23 -0
- src/pages/api/home.state.tsx +42 -0
- src/pages/api/home.tsx +157 -0
- src/pages/index.tsx +1 -13
- src/prompt/index.ts +10 -0
- src/types/chat.ts +25 -0
- src/types/data.ts +5 -0
- src/types/prompt.ts +10 -0
- src/utils/index.ts +91 -0
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 |
-
|
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");
|