+page.svelte 12.4 KB
Newer Older
Timothy J. Baek's avatar
Timothy J. Baek committed
1
<script lang="ts">
2
	import { v4 as uuidv4 } from 'uuid';
Timothy J. Baek's avatar
Timothy J. Baek committed
3
4
	import toast from 'svelte-french-toast';

5
	import { OLLAMA_API_BASE_URL } from '$lib/constants';
6
	import { onMount, tick } from 'svelte';
7
8
	import { splitStream } from '$lib/utils';
	import { goto } from '$app/navigation';
Timothy J. Baek's avatar
Timothy J. Baek committed
9

10
11
12
13
14
	import { config, user, settings, db, chats, chatId } from '$lib/stores';

	import MessageInput from '$lib/components/chat/MessageInput.svelte';
	import Messages from '$lib/components/chat/Messages.svelte';
	import ModelSelector from '$lib/components/chat/ModelSelector.svelte';
15
	import Navbar from '$lib/components/layout/Navbar.svelte';
16

17
18
	let stopResponseFlag = false;
	let autoScroll = true;
Timothy J. Baek's avatar
Timothy J. Baek committed
19

Timothy J. Baek's avatar
Timothy J. Baek committed
20
	let selectedModels = [''];
21

Timothy J. Baek's avatar
Timothy J. Baek committed
22
	let title = '';
Timothy J. Baek's avatar
Timothy J. Baek committed
23
	let prompt = '';
24

25
	let messages = [];
Timothy J. Baek's avatar
Timothy J. Baek committed
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
	let history = {
		messages: {},
		currentId: null
	};

	$: if (history.currentId !== null) {
		let _messages = [];

		let currentMessage = history.messages[history.currentId];
		while (currentMessage !== null) {
			_messages.unshift({ ...currentMessage });
			currentMessage =
				currentMessage.parentId !== null ? history.messages[currentMessage.parentId] : null;
		}
		messages = _messages;
	}
Timothy J. Baek's avatar
Timothy J. Baek committed
42

43
	onMount(async () => {
Timothy J. Baek's avatar
Timothy J. Baek committed
44
45
46
47
48
		await chatId.set(uuidv4());

		chatId.subscribe(async () => {
			await initNewChat();
		});
49
50
51
52
53
54
	});

	//////////////////////////
	// Web functions
	//////////////////////////

55
56
	const initNewChat = async () => {
		console.log($chatId);
Timothy J. Baek's avatar
Timothy J. Baek committed
57

58
		autoScroll = true;
Timothy J. Baek's avatar
Timothy J. Baek committed
59

60
61
62
63
64
		title = '';
		messages = [];
		history = {
			messages: {},
			currentId: null
Timothy J. Baek's avatar
Timothy J. Baek committed
65
		};
66
		selectedModels = $settings.models ?? [''];
Timothy J. Baek's avatar
Timothy J. Baek committed
67
68
	};

69
70
71
72
	//////////////////////////
	// Ollama functions
	//////////////////////////

Timothy J. Baek's avatar
Timothy J. Baek committed
73
	const sendPrompt = async (userPrompt, parentId) => {
Timothy J. Baek's avatar
Timothy J. Baek committed
74
75
76
77
78
79
80
81
82
		await Promise.all(
			selectedModels.map(async (model) => {
				if (model.includes('gpt-')) {
					await sendPromptOpenAI(model, userPrompt, parentId);
				} else {
					await sendPromptOllama(model, userPrompt, parentId);
				}
			})
		);
83

84
		await chats.set(await $db.getChats());
85
86
	};

Timothy J. Baek's avatar
Timothy J. Baek committed
87
	const sendPromptOllama = async (model, userPrompt, parentId) => {
Timothy J. Baek's avatar
Timothy J. Baek committed
88
		console.log('sendPromptOllama');
Timothy J. Baek's avatar
Timothy J. Baek committed
89
90
		let responseMessageId = uuidv4();

91
		let responseMessage = {
Timothy J. Baek's avatar
Timothy J. Baek committed
92
93
94
			parentId: parentId,
			id: responseMessageId,
			childrenIds: [],
95
			role: 'assistant',
Timothy J. Baek's avatar
Timothy J. Baek committed
96
97
			content: '',
			model: model
98
99
		};

Timothy J. Baek's avatar
Timothy J. Baek committed
100
101
102
103
104
105
106
107
108
		history.messages[responseMessageId] = responseMessage;
		history.currentId = responseMessageId;
		if (parentId !== null) {
			history.messages[parentId].childrenIds = [
				...history.messages[parentId].childrenIds,
				responseMessageId
			];
		}

109
110
		window.scrollTo({ top: document.body.scrollHeight });

111
		const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/generate`, {
112
113
			method: 'POST',
			headers: {
Timothy J. Baek's avatar
Timothy J. Baek committed
114
				'Content-Type': 'text/event-stream',
115
				...($settings.authHeader && { Authorization: $settings.authHeader }),
116
				...($user && { Authorization: `Bearer ${localStorage.token}` })
117
118
			},
			body: JSON.stringify({
Timothy J. Baek's avatar
Timothy J. Baek committed
119
				model: model,
120
				prompt: userPrompt,
121
				system: $settings.system ?? undefined,
122
				options: {
123
124
125
126
127
					seed: $settings.seed ?? undefined,
					temperature: $settings.temperature ?? undefined,
					repeat_penalty: $settings.repeat_penalty ?? undefined,
					top_k: $settings.top_k ?? undefined,
					top_p: $settings.top_p ?? undefined
128
				},
129
				format: $settings.requestFormat ?? undefined,
130
				context:
Timothy J. Baek's avatar
Timothy J. Baek committed
131
132
133
					history.messages[parentId] !== null &&
					history.messages[parentId].parentId in history.messages
						? history.messages[history.messages[parentId].parentId]?.context ?? undefined
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
						: undefined
			})
		});

		const reader = res.body
			.pipeThrough(new TextDecoderStream())
			.pipeThrough(splitStream('\n'))
			.getReader();

		while (true) {
			const { value, done } = await reader.read();
			if (done || stopResponseFlag) {
				if (stopResponseFlag) {
					responseMessage.done = true;
					messages = messages;
				}

				break;
			}

			try {
				let lines = value.split('\n');

				for (const line of lines) {
					if (line !== '') {
						console.log(line);
						let data = JSON.parse(line);
						if (data.done == false) {
							if (responseMessage.content == '' && data.response == '\n') {
								continue;
							} else {
								responseMessage.content += data.response;
								messages = messages;
							}
168
169
						} else if ('detail' in data) {
							throw data;
170
171
172
173
174
175
176
177
178
						} else {
							responseMessage.done = true;
							responseMessage.context = data.context;
							messages = messages;
						}
					}
				}
			} catch (error) {
				console.log(error);
179
180
181
182
				if ('detail' in error) {
					toast.error(error.detail);
				}
				break;
183
184
185
186
187
188
			}

			if (autoScroll) {
				window.scrollTo({ top: document.body.scrollHeight });
			}

189
			await $db.updateChatById($chatId, {
190
				title: title === '' ? 'New Chat' : title,
Timothy J. Baek's avatar
Timothy J. Baek committed
191
				models: selectedModels,
192
				system: $settings.system ?? undefined,
193
				options: {
194
195
196
197
198
					seed: $settings.seed ?? undefined,
					temperature: $settings.temperature ?? undefined,
					repeat_penalty: $settings.repeat_penalty ?? undefined,
					top_k: $settings.top_k ?? undefined,
					top_p: $settings.top_p ?? undefined
199
				},
Timothy J. Baek's avatar
Timothy J. Baek committed
200
				messages: messages,
201
				history: history
202
203
204
205
206
207
208
209
			});
		}

		stopResponseFlag = false;
		await tick();
		if (autoScroll) {
			window.scrollTo({ top: document.body.scrollHeight });
		}
210

211
		if (messages.length == 2 && messages.at(1).content !== '') {
212
213
			window.history.replaceState(history.state, '', `/c/${$chatId}`);
			await generateChatTitle($chatId, userPrompt);
214
215
216
		}
	};

Timothy J. Baek's avatar
Timothy J. Baek committed
217
	const sendPromptOpenAI = async (model, userPrompt, parentId) => {
Timothy J. Baek's avatar
Timothy J. Baek committed
218
		if ($settings.OPENAI_API_KEY) {
219
			if (models) {
220
221
				let responseMessageId = uuidv4();

222
				let responseMessage = {
223
224
225
					parentId: parentId,
					id: responseMessageId,
					childrenIds: [],
226
					role: 'assistant',
Timothy J. Baek's avatar
Timothy J. Baek committed
227
228
					content: '',
					model: model
229
230
				};

231
232
233
234
235
236
237
238
239
				history.messages[responseMessageId] = responseMessage;
				history.currentId = responseMessageId;
				if (parentId !== null) {
					history.messages[parentId].childrenIds = [
						...history.messages[parentId].childrenIds,
						responseMessageId
					];
				}

240
241
242
243
244
245
				window.scrollTo({ top: document.body.scrollHeight });

				const res = await fetch(`https://api.openai.com/v1/chat/completions`, {
					method: 'POST',
					headers: {
						'Content-Type': 'application/json',
Timothy J. Baek's avatar
Timothy J. Baek committed
246
						Authorization: `Bearer ${$settings.OPENAI_API_KEY}`
247
248
					},
					body: JSON.stringify({
Timothy J. Baek's avatar
Timothy J. Baek committed
249
						model: model,
250
						stream: true,
251
						messages: [
252
							$settings.system
253
254
								? {
										role: 'system',
Timothy J. Baek's avatar
Timothy J. Baek committed
255
										content: $settings.system
256
257
258
259
260
								  }
								: undefined,
							...messages
						]
							.filter((message) => message)
261
							.map((message) => ({ role: message.role, content: message.content })),
262
263
264
						temperature: $settings.temperature ?? undefined,
						top_p: $settings.top_p ?? undefined,
						frequency_penalty: $settings.repeat_penalty ?? undefined
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
					})
				});

				const reader = res.body
					.pipeThrough(new TextDecoderStream())
					.pipeThrough(splitStream('\n'))
					.getReader();

				while (true) {
					const { value, done } = await reader.read();
					if (done || stopResponseFlag) {
						if (stopResponseFlag) {
							responseMessage.done = true;
							messages = messages;
						}

						break;
					}

					try {
						let lines = value.split('\n');

						for (const line of lines) {
							if (line !== '') {
								console.log(line);
								if (line === 'data: [DONE]') {
									responseMessage.done = true;
									messages = messages;
								} else {
									let data = JSON.parse(line.replace(/^data: /, ''));
									console.log(data);

									if (responseMessage.content == '' && data.choices[0].delta.content == '\n') {
										continue;
									} else {
										responseMessage.content += data.choices[0].delta.content ?? '';
										messages = messages;
									}
								}
							}
						}
					} catch (error) {
						console.log(error);
					}

					if (autoScroll) {
						window.scrollTo({ top: document.body.scrollHeight });
					}

314
					await $db.updateChatById($chatId, {
315
						title: title === '' ? 'New Chat' : title,
Timothy J. Baek's avatar
Timothy J. Baek committed
316
						models: selectedModels,
317
						system: $settings.system ?? undefined,
318
						options: {
319
320
321
322
323
							seed: $settings.seed ?? undefined,
							temperature: $settings.temperature ?? undefined,
							repeat_penalty: $settings.repeat_penalty ?? undefined,
							top_k: $settings.top_k ?? undefined,
							top_p: $settings.top_p ?? undefined
324
						},
Timothy J. Baek's avatar
Timothy J. Baek committed
325
						messages: messages,
326
						history: history
327
328
329
330
331
332
333
334
335
336
337
					});
				}

				stopResponseFlag = false;

				await tick();
				if (autoScroll) {
					window.scrollTo({ top: document.body.scrollHeight });
				}

				if (messages.length == 2) {
338
339
					window.history.replaceState(history.state, '', `/c/${$chatId}`);
					await setChatTitle($chatId, userPrompt);
340
341
342
				}
			}
		}
343
344
345
	};

	const submitPrompt = async (userPrompt) => {
Timothy J. Baek's avatar
Timothy J. Baek committed
346
		console.log('submitPrompt');
347

Timothy J. Baek's avatar
Timothy J. Baek committed
348
		if (selectedModels.includes('')) {
Timothy J. Baek's avatar
Timothy J. Baek committed
349
			toast.error('Model not selected');
350
		} else if (messages.length != 0 && messages.at(-1).done != true) {
Timothy J. Baek's avatar
Timothy J. Baek committed
351
352
			console.log('wait');
		} else {
Timothy J. Baek's avatar
Timothy J. Baek committed
353
354
			document.getElementById('chat-textarea').style.height = '';

Timothy J. Baek's avatar
Timothy J. Baek committed
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
			let userMessageId = uuidv4();
			let userMessage = {
				id: userMessageId,
				parentId: messages.length !== 0 ? messages.at(-1).id : null,
				childrenIds: [],
				role: 'user',
				content: userPrompt
			};

			if (messages.length !== 0) {
				history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
			}

			history.messages[userMessageId] = userMessage;
			history.currentId = userMessageId;
Timothy J. Baek's avatar
Timothy J. Baek committed
370
371
372

			prompt = '';

373
			if (messages.length == 0) {
374
				await $db.createNewChat({
375
376
					id: $chatId,
					title: 'New Chat',
Timothy J. Baek's avatar
Timothy J. Baek committed
377
					models: selectedModels,
378
					system: $settings.system ?? undefined,
379
					options: {
380
381
382
383
384
						seed: $settings.seed ?? undefined,
						temperature: $settings.temperature ?? undefined,
						repeat_penalty: $settings.repeat_penalty ?? undefined,
						top_k: $settings.top_k ?? undefined,
						top_p: $settings.top_p ?? undefined
385
					},
Timothy J. Baek's avatar
Timothy J. Baek committed
386
					messages: messages,
387
					history: history
388
389
				});
			}
390

391
392
393
394
			setTimeout(() => {
				window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
			}, 50);

Timothy J. Baek's avatar
Timothy J. Baek committed
395
			await sendPrompt(userPrompt, userMessageId);
Timothy J. Baek's avatar
Timothy J. Baek committed
396
397
398
		}
	};

399
400
401
402
403
	const stopResponse = () => {
		stopResponseFlag = true;
		console.log('stopResponse');
	};

404
405
406
407
408
	const regenerateResponse = async () => {
		console.log('regenerateResponse');
		if (messages.length != 0 && messages.at(-1).done == true) {
			messages.splice(messages.length - 1, 1);
			messages = messages;
Timothy J. Baek's avatar
Timothy J. Baek committed
409

410
411
			let userMessage = messages.at(-1);
			let userPrompt = userMessage.content;
412

Timothy J. Baek's avatar
Timothy J. Baek committed
413
			await sendPrompt(userPrompt, userMessage.id);
Timothy J. Baek's avatar
Timothy J. Baek committed
414
		}
415
	};
416

417
	const generateChatTitle = async (_chatId, userPrompt) => {
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
		if ($settings.titleAutoGenerate ?? true) {
			console.log('generateChatTitle');

			const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/generate`, {
				method: 'POST',
				headers: {
					'Content-Type': 'text/event-stream',
					...($settings.authHeader && { Authorization: $settings.authHeader }),
					...($user && { Authorization: `Bearer ${localStorage.token}` })
				},
				body: JSON.stringify({
					model: selectedModels[0],
					prompt: `Generate a brief 3-5 word title for this question, excluding the term 'title.' Then, please reply with only the title: ${userPrompt}`,
					stream: false
				})
433
			})
434
435
436
437
438
439
440
441
442
443
444
				.then(async (res) => {
					if (!res.ok) throw await res.json();
					return res.json();
				})
				.catch((error) => {
					if ('detail' in error) {
						toast.error(error.detail);
					}
					console.log(error);
					return null;
				});
445

446
447
448
449
450
			if (res) {
				await setChatTitle(_chatId, res.response === '' ? 'New Chat' : res.response);
			}
		} else {
			await setChatTitle(_chatId, `${userPrompt}`);
451
452
453
454
		}
	};

	const setChatTitle = async (_chatId, _title) => {
455
456
		await $db.updateChatById(_chatId, { title: _title });
		if (_chatId === $chatId) {
457
			title = _title;
458
459
		}
	};
Timothy J. Baek's avatar
Timothy J. Baek committed
460
461
</script>

462
463
<svelte:window
	on:scroll={(e) => {
Timothy J. Baek's avatar
Timothy J. Baek committed
464
		autoScroll = window.innerHeight + window.scrollY >= document.body.offsetHeight - 40;
465
466
467
	}}
/>

468
469
470
471
472
<Navbar {title} />
<div class="min-h-screen w-full flex justify-center">
	<div class=" py-2.5 flex flex-col justify-between w-full">
		<div class="max-w-2xl mx-auto w-full px-3 md:px-0 mt-10">
			<ModelSelector bind:selectedModels disabled={messages.length > 0} />
Timothy J. Baek's avatar
Timothy J. Baek committed
473
		</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
474

475
476
477
478
		<div class=" h-full mt-10 mb-32 w-full flex flex-col">
			<Messages bind:history bind:messages bind:autoScroll {sendPrompt} {regenerateResponse} />
		</div>
	</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
479

480
481
	<MessageInput bind:prompt bind:autoScroll {messages} {submitPrompt} {stopResponse} />
</div>