+page.svelte 15.2 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
	import { config, modelfiles, user, settings, db, chats, chatId } from '$lib/stores';
11
12
13
14

	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
	import { page } from '$app/stores';
17

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

Timothy J. Baek's avatar
Timothy J. Baek committed
21
	let selectedModels = [''];
22
23
24
25
26
27
	let selectedModelfile = null;
	$: selectedModelfile =
		selectedModels.length === 1 &&
		$modelfiles.filter((modelfile) => modelfile.tagName === selectedModels[0]).length > 0
			? $modelfiles.filter((modelfile) => modelfile.tagName === selectedModels[0])[0]
			: null;
28

Timothy J. Baek's avatar
Timothy J. Baek committed
29
	let title = '';
Timothy J. Baek's avatar
Timothy J. Baek committed
30
	let prompt = '';
31
	let files = [];
32

33
	let messages = [];
Timothy J. Baek's avatar
Timothy J. Baek committed
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
	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
50

51
	onMount(async () => {
Timothy J. Baek's avatar
Timothy J. Baek committed
52
53
54
55
56
		await chatId.set(uuidv4());

		chatId.subscribe(async () => {
			await initNewChat();
		});
57
58
59
60
61
62
	});

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

63
64
	const initNewChat = async () => {
		console.log($chatId);
Timothy J. Baek's avatar
Timothy J. Baek committed
65

66
		autoScroll = true;
Timothy J. Baek's avatar
Timothy J. Baek committed
67

68
69
70
71
72
		title = '';
		messages = [];
		history = {
			messages: {},
			currentId: null
Timothy J. Baek's avatar
Timothy J. Baek committed
73
		};
74
75
76
		selectedModels = $page.url.searchParams.get('models')
			? $page.url.searchParams.get('models')?.split(',')
			: $settings.models ?? [''];
Timothy J. Baek's avatar
Timothy J. Baek committed
77
78
	};

79
80
81
82
	//////////////////////////
	// Ollama functions
	//////////////////////////

Timothy J. Baek's avatar
Timothy J. Baek committed
83
	const sendPrompt = async (userPrompt, parentId) => {
Timothy J. Baek's avatar
Timothy J. Baek committed
84
85
86
87
88
89
90
91
92
		await Promise.all(
			selectedModels.map(async (model) => {
				if (model.includes('gpt-')) {
					await sendPromptOpenAI(model, userPrompt, parentId);
				} else {
					await sendPromptOllama(model, userPrompt, parentId);
				}
			})
		);
93

94
		await chats.set(await $db.getChats());
95
96
	};

Timothy J. Baek's avatar
Timothy J. Baek committed
97
	const sendPromptOllama = async (model, userPrompt, parentId) => {
Timothy J. Baek's avatar
Timothy J. Baek committed
98
		console.log('sendPromptOllama');
Timothy J. Baek's avatar
Timothy J. Baek committed
99
100
		let responseMessageId = uuidv4();

101
		let responseMessage = {
Timothy J. Baek's avatar
Timothy J. Baek committed
102
103
104
			parentId: parentId,
			id: responseMessageId,
			childrenIds: [],
105
			role: 'assistant',
Timothy J. Baek's avatar
Timothy J. Baek committed
106
107
			content: '',
			model: model
108
109
		};

Timothy J. Baek's avatar
Timothy J. Baek committed
110
111
112
113
114
115
116
117
118
		history.messages[responseMessageId] = responseMessage;
		history.currentId = responseMessageId;
		if (parentId !== null) {
			history.messages[parentId].childrenIds = [
				...history.messages[parentId].childrenIds,
				responseMessageId
			];
		}

119
120
		window.scrollTo({ top: document.body.scrollHeight });

121
		const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/generate`, {
122
123
			method: 'POST',
			headers: {
Timothy J. Baek's avatar
Timothy J. Baek committed
124
				'Content-Type': 'text/event-stream',
125
				...($settings.authHeader && { Authorization: $settings.authHeader }),
126
				...($user && { Authorization: `Bearer ${localStorage.token}` })
127
128
			},
			body: JSON.stringify({
Timothy J. Baek's avatar
Timothy J. Baek committed
129
				model: model,
130
				prompt: userPrompt,
131
				system: $settings.system ?? undefined,
132
				options: {
133
134
135
136
					seed: $settings.seed ?? undefined,
					temperature: $settings.temperature ?? undefined,
					repeat_penalty: $settings.repeat_penalty ?? undefined,
					top_k: $settings.top_k ?? undefined,
Anthony Cucci's avatar
Anthony Cucci committed
137
					top_p: $settings.top_p ?? undefined,
138
139
					num_ctx: $settings.num_ctx ?? undefined,
					...($settings.options ?? {})
140
				},
141
				format: $settings.requestFormat ?? undefined,
142
				context:
Timothy J. Baek's avatar
Timothy J. Baek committed
143
144
145
					history.messages[parentId] !== null &&
					history.messages[parentId].parentId in history.messages
						? history.messages[history.messages[parentId].parentId]?.context ?? undefined
146
147
148
149
						: undefined
			})
		});

Timothy J. Baek's avatar
Timothy J. Baek committed
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
		// const res = await fetch(`${$settings?.API_BASE_URL ?? OLLAMA_API_BASE_URL}/chat`, {
		// 	method: 'POST',
		// 	headers: {
		// 		'Content-Type': 'text/event-stream',
		// 		...($settings.authHeader && { Authorization: $settings.authHeader }),
		// 		...($user && { Authorization: `Bearer ${localStorage.token}` })
		// 	},
		// 	body: JSON.stringify({
		// 		model: model,
		// 		messages: [
		// 			$settings.system
		// 				? {
		// 						role: 'system',
		// 						content: $settings.system
		// 				  }
		// 				: undefined,
		// 			...messages
		// 		]
		// 			.filter((message) => message)
		// 			.map((message) => ({ role: message.role, content: message.content })),
		// 		options: {
		// 			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,
		// 			num_ctx: $settings.num_ctx ?? undefined,
		// 			...($settings.options ?? {})
		// 		},
		// 		format: $settings.requestFormat ?? undefined
		// 	})
		// });

183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
		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;
							}
213
214
						} else if ('detail' in data) {
							throw data;
215
216
217
218
219
220
221
222
223
						} else {
							responseMessage.done = true;
							responseMessage.context = data.context;
							messages = messages;
						}
					}
				}
			} catch (error) {
				console.log(error);
224
225
226
227
				if ('detail' in error) {
					toast.error(error.detail);
				}
				break;
228
229
230
231
232
233
			}

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

234
			await $db.updateChatById($chatId, {
235
				title: title === '' ? 'New Chat' : title,
Timothy J. Baek's avatar
Timothy J. Baek committed
236
				models: selectedModels,
237
				system: $settings.system ?? undefined,
238
				options: {
239
240
241
242
					seed: $settings.seed ?? undefined,
					temperature: $settings.temperature ?? undefined,
					repeat_penalty: $settings.repeat_penalty ?? undefined,
					top_k: $settings.top_k ?? undefined,
Anthony Cucci's avatar
Anthony Cucci committed
243
					top_p: $settings.top_p ?? undefined,
244
245
					num_ctx: $settings.num_ctx ?? undefined,
					...($settings.options ?? {})
246
				},
Timothy J. Baek's avatar
Timothy J. Baek committed
247
				messages: messages,
248
				history: history
249
250
251
252
253
254
255
256
			});
		}

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

258
		if (messages.length == 2 && messages.at(1).content !== '') {
259
260
			window.history.replaceState(history.state, '', `/c/${$chatId}`);
			await generateChatTitle($chatId, userPrompt);
261
262
263
		}
	};

Timothy J. Baek's avatar
Timothy J. Baek committed
264
	const sendPromptOpenAI = async (model, userPrompt, parentId) => {
Timothy J. Baek's avatar
Timothy J. Baek committed
265
		if ($settings.OPENAI_API_KEY) {
266
			if (models) {
267
268
				let responseMessageId = uuidv4();

269
				let responseMessage = {
270
271
272
					parentId: parentId,
					id: responseMessageId,
					childrenIds: [],
273
					role: 'assistant',
Timothy J. Baek's avatar
Timothy J. Baek committed
274
275
					content: '',
					model: model
276
277
				};

278
279
280
281
282
283
284
285
286
				history.messages[responseMessageId] = responseMessage;
				history.currentId = responseMessageId;
				if (parentId !== null) {
					history.messages[parentId].childrenIds = [
						...history.messages[parentId].childrenIds,
						responseMessageId
					];
				}

Timothy J. Baek's avatar
Timothy J. Baek committed
287
288
				await tick();

289
290
291
292
293
294
				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
295
						Authorization: `Bearer ${$settings.OPENAI_API_KEY}`
296
297
					},
					body: JSON.stringify({
Timothy J. Baek's avatar
Timothy J. Baek committed
298
						model: model,
299
						stream: true,
300
						messages: [
301
							$settings.system
302
303
								? {
										role: 'system',
Timothy J. Baek's avatar
Timothy J. Baek committed
304
										content: $settings.system
305
306
307
308
309
								  }
								: undefined,
							...messages
						]
							.filter((message) => message)
310
							.map((message) => ({ role: message.role, content: message.content })),
311
312
						temperature: $settings.temperature ?? undefined,
						top_p: $settings.top_p ?? undefined,
313
						num_ctx: $settings.num_ctx ?? undefined,
314
						frequency_penalty: $settings.repeat_penalty ?? undefined
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
					})
				});

				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 });
					}

364
					await $db.updateChatById($chatId, {
365
						title: title === '' ? 'New Chat' : title,
Timothy J. Baek's avatar
Timothy J. Baek committed
366
						models: selectedModels,
367
						system: $settings.system ?? undefined,
368
						options: {
369
370
371
372
							seed: $settings.seed ?? undefined,
							temperature: $settings.temperature ?? undefined,
							repeat_penalty: $settings.repeat_penalty ?? undefined,
							top_k: $settings.top_k ?? undefined,
Anthony Cucci's avatar
Anthony Cucci committed
373
							top_p: $settings.top_p ?? undefined,
374
375
							num_ctx: $settings.num_ctx ?? undefined,
							...($settings.options ?? {})
376
						},
Timothy J. Baek's avatar
Timothy J. Baek committed
377
						messages: messages,
378
						history: history
379
380
381
382
383
384
385
386
387
388
389
					});
				}

				stopResponseFlag = false;

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

				if (messages.length == 2) {
390
391
					window.history.replaceState(history.state, '', `/c/${$chatId}`);
					await setChatTitle($chatId, userPrompt);
392
393
394
				}
			}
		}
395
396
397
	};

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

Timothy J. Baek's avatar
Timothy J. Baek committed
400
		if (selectedModels.includes('')) {
Timothy J. Baek's avatar
Timothy J. Baek committed
401
			toast.error('Model not selected');
402
		} else if (messages.length != 0 && messages.at(-1).done != true) {
Timothy J. Baek's avatar
Timothy J. Baek committed
403
404
			console.log('wait');
		} else {
Timothy J. Baek's avatar
Timothy J. Baek committed
405
406
			document.getElementById('chat-textarea').style.height = '';

Timothy J. Baek's avatar
Timothy J. Baek committed
407
408
409
410
411
412
			let userMessageId = uuidv4();
			let userMessage = {
				id: userMessageId,
				parentId: messages.length !== 0 ? messages.at(-1).id : null,
				childrenIds: [],
				role: 'user',
413
414
				content: userPrompt,
				files: files.length > 0 ? files : undefined
Timothy J. Baek's avatar
Timothy J. Baek committed
415
416
417
418
419
420
421
422
			};

			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
423
424

			prompt = '';
425
			files = [];
Timothy J. Baek's avatar
Timothy J. Baek committed
426

427
			if (messages.length == 0) {
428
				await $db.createNewChat({
429
430
					id: $chatId,
					title: 'New Chat',
Timothy J. Baek's avatar
Timothy J. Baek committed
431
					models: selectedModels,
432
					system: $settings.system ?? undefined,
433
					options: {
434
435
436
437
						seed: $settings.seed ?? undefined,
						temperature: $settings.temperature ?? undefined,
						repeat_penalty: $settings.repeat_penalty ?? undefined,
						top_k: $settings.top_k ?? undefined,
Anthony Cucci's avatar
Anthony Cucci committed
438
						top_p: $settings.top_p ?? undefined,
439
440
						num_ctx: $settings.num_ctx ?? undefined,
						...($settings.options ?? {})
441
					},
Timothy J. Baek's avatar
Timothy J. Baek committed
442
					messages: messages,
443
					history: history
444
445
				});
			}
446

447
448
449
450
			setTimeout(() => {
				window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
			}, 50);

Timothy J. Baek's avatar
Timothy J. Baek committed
451
			await sendPrompt(userPrompt, userMessageId);
Timothy J. Baek's avatar
Timothy J. Baek committed
452
453
454
		}
	};

455
456
457
458
459
	const stopResponse = () => {
		stopResponseFlag = true;
		console.log('stopResponse');
	};

460
461
462
463
464
	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
465

466
467
			let userMessage = messages.at(-1);
			let userPrompt = userMessage.content;
468

Timothy J. Baek's avatar
Timothy J. Baek committed
469
			await sendPrompt(userPrompt, userMessage.id);
Timothy J. Baek's avatar
Timothy J. Baek committed
470
		}
471
	};
472

473
	const generateChatTitle = async (_chatId, userPrompt) => {
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
		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
				})
489
			})
490
491
492
493
494
495
496
497
498
499
500
				.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;
				});
501

502
503
504
505
506
			if (res) {
				await setChatTitle(_chatId, res.response === '' ? 'New Chat' : res.response);
			}
		} else {
			await setChatTitle(_chatId, `${userPrompt}`);
507
508
509
510
		}
	};

	const setChatTitle = async (_chatId, _title) => {
511
512
		await $db.updateChatById(_chatId, { title: _title });
		if (_chatId === $chatId) {
513
			title = _title;
514
515
		}
	};
Timothy J. Baek's avatar
Timothy J. Baek committed
516
517
</script>

518
519
<svelte:window
	on:scroll={(e) => {
Timothy J. Baek's avatar
Timothy J. Baek committed
520
		autoScroll = window.innerHeight + window.scrollY >= document.body.offsetHeight - 40;
521
522
523
	}}
/>

524
525
526
527
528
<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
529
		</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
530

531
		<div class=" h-full mt-10 mb-32 w-full flex flex-col">
532
533
534
535
536
537
538
539
540
			<Messages
				{selectedModels}
				{selectedModelfile}
				bind:history
				bind:messages
				bind:autoScroll
				{sendPrompt}
				{regenerateResponse}
			/>
541
542
		</div>
	</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
543

544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
	<MessageInput
		bind:prompt
		bind:files
		bind:autoScroll
		suggestionPrompts={selectedModelfile?.suggestionPrompts ?? [
			{
				title: ['Help me study', 'vocabulary for a college entrance exam'],
				content: `Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.`
			},
			{
				title: ['Give me ideas', `for what to do with my kids' art`],
				content: `What are 5 creative things I could do with my kids' art? I don't want to throw them away, but it's also so much clutter.`
			},
			{
				title: ['Tell me a fun fact', 'about the Roman Empire'],
				content: 'Tell me a random fun fact about the Roman Empire'
			},
			{
				title: ['Show me a code snippet', `of a website's sticky header`],
				content: `Show me a code snippet of a website's sticky header in CSS and JavaScript.`
			}
		]}
		{messages}
		{submitPrompt}
		{stopResponse}
	/>
570
</div>