Selector.svelte 16.1 KB
Newer Older
1
<script lang="ts">
2
	import { DropdownMenu } from 'bits-ui';
Timothy J. Baek's avatar
Timothy J. Baek committed
3
	import { marked } from 'marked';
4
	import Fuse from 'fuse.js';
5
6

	import { flyAndScale } from '$lib/utils/transitions';
7
	import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
8
9
10
11
12

	import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
	import Check from '$lib/components/icons/Check.svelte';
	import Search from '$lib/components/icons/Search.svelte';

13
	import { deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama';
14

15
	import { user, MODEL_DOWNLOAD_POOL, models, mobile } from '$lib/stores';
16
	import { toast } from 'svelte-sonner';
Timothy J. Baek's avatar
refac  
Timothy J. Baek committed
17
18
19
	import { capitalizeFirstLetter, sanitizeResponseContent, splitStream } from '$lib/utils';
	import { getModels } from '$lib/apis';

20
21
22
23
24
25
26
27
	import Tooltip from '$lib/components/common/Tooltip.svelte';

	const i18n = getContext('i18n');
	const dispatch = createEventDispatcher();

	export let value = '';
	export let placeholder = 'Select a model';
	export let searchEnabled = true;
28
	export let searchPlaceholder = $i18n.t('Search a model');
29

30
31
32
33
34
35
	export let items: {
		label: string;
		value: string;
		// eslint-disable-next-line @typescript-eslint/no-explicit-any
		[key: string]: any;
	} = [];
36

Timothy J. Baek's avatar
Timothy J. Baek committed
37
	export let className = 'w-[32rem]';
38

Timothy J. Baek's avatar
Timothy J. Baek committed
39
	let show = false;
40
41
42
43

	let selectedModel = '';
	$: selectedModel = items.find((item) => item.value === value) ?? '';

44
45
46
	let searchValue = '';
	let ollamaVersion = null;

47
	let selectedModelIdx = 0;
48

49
50
51
52
53
54
55
	const fuse = new Fuse(
		items
			.filter((item) => !item.model?.info?.meta?.hidden)
			.map((item) => {
				const _item = {
					...item,
					modelName: item.model?.name,
56
57
					tags: item.model?.info?.meta?.tags?.map((tag) => tag.name).join(' '),
					desc: item.model?.info?.meta?.description
58
59
60
61
				};
				return _item;
			}),
		{
62
			keys: ['value', 'label', 'tags', 'desc', 'modelName']
63
		}
64
	);
65

66
67
68
69
70
71
	$: filteredItems = searchValue
		? fuse.search(searchValue).map((e) => {
				return e.item;
		  })
		: items.filter((item) => !item.model?.info?.meta?.hidden);

72
	const pullModelHandler = async () => {
Timothy J. Baek's avatar
Timothy J. Baek committed
73
		const sanitizedModelTag = searchValue.trim().replace(/^ollama\s+(run|pull)\s+/, '');
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

		console.log($MODEL_DOWNLOAD_POOL);
		if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag]) {
			toast.error(
				$i18n.t(`Model '{{modelTag}}' is already in queue for downloading.`, {
					modelTag: sanitizedModelTag
				})
			);
			return;
		}
		if (Object.keys($MODEL_DOWNLOAD_POOL).length === 3) {
			toast.error(
				$i18n.t('Maximum of 3 models can be downloaded simultaneously. Please try again later.')
			);
			return;
		}

91
92
93
94
95
96
		const [res, controller] = await pullModel(localStorage.token, sanitizedModelTag, '0').catch(
			(error) => {
				toast.error(error);
				return null;
			}
		);
97
98
99
100
101
102
103

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

104
105
106
107
108
109
110
111
112
113
			MODEL_DOWNLOAD_POOL.set({
				...$MODEL_DOWNLOAD_POOL,
				[sanitizedModelTag]: {
					...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
					abortController: controller,
					reader,
					done: false
				}
			});

114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
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
168
169
170
			while (true) {
				try {
					const { value, done } = await reader.read();
					if (done) break;

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

					for (const line of lines) {
						if (line !== '') {
							let data = JSON.parse(line);
							console.log(data);
							if (data.error) {
								throw data.error;
							}
							if (data.detail) {
								throw data.detail;
							}

							if (data.status) {
								if (data.digest) {
									let downloadProgress = 0;
									if (data.completed) {
										downloadProgress = Math.round((data.completed / data.total) * 1000) / 10;
									} else {
										downloadProgress = 100;
									}

									MODEL_DOWNLOAD_POOL.set({
										...$MODEL_DOWNLOAD_POOL,
										[sanitizedModelTag]: {
											...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
											pullProgress: downloadProgress,
											digest: data.digest
										}
									});
								} else {
									toast.success(data.status);

									MODEL_DOWNLOAD_POOL.set({
										...$MODEL_DOWNLOAD_POOL,
										[sanitizedModelTag]: {
											...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
											done: data.status === 'success'
										}
									});
								}
							}
						}
					}
				} catch (error) {
					console.log(error);
					if (typeof error !== 'string') {
						error = error.message;
					}

					toast.error(error);
					// opts.callback({ success: false, error, modelName: opts.modelName });
171
					break;
172
173
174
175
176
177
178
179
180
				}
			}

			if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag].done) {
				toast.success(
					$i18n.t(`Model '{{modelName}}' has been successfully downloaded.`, {
						modelName: sanitizedModelTag
					})
				);
181

Timothy J. Baek's avatar
refac  
Timothy J. Baek committed
182
				models.set(await getModels(localStorage.token));
183
			} else {
Jannik Streidl's avatar
Jannik Streidl committed
184
				toast.error($i18n.t('Download canceled'));
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
			}

			delete $MODEL_DOWNLOAD_POOL[sanitizedModelTag];

			MODEL_DOWNLOAD_POOL.set({
				...$MODEL_DOWNLOAD_POOL
			});
		}
	};

	onMount(async () => {
		ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false);
	});

	const cancelModelPullHandler = async (model: string) => {
200
201
202
203
		const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model];
		if (abortController) {
			abortController.abort();
		}
204
205
206
207
208
209
210
211
212
213
214
215
		if (reader) {
			await reader.cancel();
			delete $MODEL_DOWNLOAD_POOL[model];
			MODEL_DOWNLOAD_POOL.set({
				...$MODEL_DOWNLOAD_POOL
			});
			await deleteModel(localStorage.token, model);
			toast.success(`${model} download has been canceled`);
		}
	};
</script>

216
<DropdownMenu.Root
Timothy J. Baek's avatar
Timothy J. Baek committed
217
	bind:open={show}
218
	onOpenChange={async () => {
219
		searchValue = '';
220
		selectedModelIdx = 0;
221
		window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0);
222
	}}
Timothy J. Baek's avatar
Timothy J. Baek committed
223
	closeFocus={false}
224
>
Timothy J. Baek's avatar
Timothy J. Baek committed
225
	<DropdownMenu.Trigger class="relative w-full font-primary" aria-label={placeholder}>
226
227
228
229
230
231
232
233
234
235
236
		<div
			class="flex w-full text-left px-0.5 outline-none bg-transparent truncate text-lg font-semibold placeholder-gray-400 focus:outline-none"
		>
			{#if selectedModel}
				{selectedModel.label}
			{:else}
				{placeholder}
			{/if}
			<ChevronDown className=" self-center ml-2 size-3" strokeWidth="2.5" />
		</div>
	</DropdownMenu.Trigger>
237

238
	<DropdownMenu.Content
Timothy J. Baek's avatar
Timothy J. Baek committed
239
240
		class=" z-40 {$mobile
			? `w-full`
Timothy J. Baek's avatar
refac  
Timothy J. Baek committed
241
			: `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-xl  bg-white dark:bg-gray-850 dark:text-white shadow-lg border border-gray-300/30 dark:border-gray-700/40  outline-none"
242
		transition={flyAndScale}
243
		side={$mobile ? 'bottom' : 'bottom-start'}
244
245
246
247
248
249
250
251
		sideOffset={4}
	>
		<slot>
			{#if searchEnabled}
				<div class="flex items-center gap-2.5 px-5 mt-3.5 mb-3">
					<Search className="size-4" strokeWidth="2.5" />

					<input
252
						id="model-search-input"
253
254
255
						bind:value={searchValue}
						class="w-full text-sm bg-transparent outline-none"
						placeholder={searchPlaceholder}
Timothy J. Baek's avatar
Timothy J. Baek committed
256
						autocomplete="off"
257
258
						on:keydown={(e) => {
							if (e.code === 'Enter' && filteredItems.length > 0) {
259
								value = filteredItems[selectedModelIdx].value;
260
								show = false;
261
262
								return; // dont need to scroll on selection
							} else if (e.code === 'ArrowDown') {
263
								selectedModelIdx = Math.min(selectedModelIdx + 1, filteredItems.length - 1);
264
							} else if (e.code === 'ArrowUp') {
265
								selectedModelIdx = Math.max(selectedModelIdx - 1, 0);
266
267
							} else {
								// if the user types something, reset to the top selection.
268
								selectedModelIdx = 0;
269
							}
270

271
							const item = document.querySelector(`[data-arrow-selected="true"]`);
272
							item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
273
						}}
274
275
276
277
278
279
					/>
				</div>

				<hr class="border-gray-100 dark:border-gray-800" />
			{/if}

280
281
			<div class="px-3 my-2 max-h-64 overflow-y-auto scrollbar-hidden group">
				{#each filteredItems as item, index}
Timothy J. Baek's avatar
Timothy J. Baek committed
282
					<button
Timothy J. Baek's avatar
Timothy J. Baek committed
283
						aria-label="model-item"
284
						class="flex w-full text-left font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-none transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-[highlighted]:bg-muted {index ===
285
						selectedModelIdx
286
287
							? 'bg-gray-100 dark:bg-gray-800 group-hover:bg-transparent'
							: ''}"
288
						data-arrow-selected={index === selectedModelIdx}
289
290
						on:click={() => {
							value = item.value;
291
							selectedModelIdx = index;
Timothy J. Baek's avatar
Timothy J. Baek committed
292
293

							show = false;
294
						}}
295
					>
296
297
298
						<div class="flex flex-col">
							{#if $mobile && (item?.model?.info?.meta?.tags ?? []).length > 0}
								<div class="flex gap-0.5 self-start h-full mb-0.5 -translate-x-1">
Timothy J. Baek's avatar
Timothy J. Baek committed
299
300
									{#each item.model?.info?.meta.tags as tag}
										<div
Timothy J. Baek's avatar
Timothy J. Baek committed
301
											class=" text-xs font-bold px-1 rounded uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
Timothy J. Baek's avatar
Timothy J. Baek committed
302
303
304
305
306
307
										>
											{tag.name}
										</div>
									{/each}
								</div>
							{/if}
308
							<div class="flex items-center gap-2">
Timothy J. Baek's avatar
Timothy J. Baek committed
309
								<div class="flex items-center min-w-fit">
310
									<div class="line-clamp-1">
311
312
313
										<div class="flex items-center min-w-fit">
											<img
												src={item.model?.info?.meta?.profile_image_url ?? '/static/favicon.png'}
Timothy J. Baek's avatar
refac  
Timothy J. Baek committed
314
315
												alt="Model"
												class="rounded-full size-5 flex items-center mr-2"
316
317
318
											/>
											{item.label}
										</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
319
									</div>
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
									{#if item.model.owned_by === 'ollama' && (item.model.ollama?.details?.parameter_size ?? '') !== ''}
										<div class="flex ml-1 items-center translate-y-[0.5px]">
											<Tooltip
												content={`${
													item.model.ollama?.details?.quantization_level
														? item.model.ollama?.details?.quantization_level + ' '
														: ''
												}${
													item.model.ollama?.size
														? `(${(item.model.ollama?.size / 1024 ** 3).toFixed(1)}GB)`
														: ''
												}`}
												className="self-end"
											>
												<span
													class=" text-xs font-medium text-gray-600 dark:text-gray-400 line-clamp-1"
													>{item.model.ollama?.details?.parameter_size ?? ''}</span
												>
											</Tooltip>
										</div>
									{/if}
								</div>
342

343
344
345
346
								<!-- {JSON.stringify(item.info)} -->

								{#if item.model.owned_by === 'openai'}
									<Tooltip content={`${'External'}`}>
Timothy J. Baek's avatar
Timothy J. Baek committed
347
										<div class="translate-y-[1px]">
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
											<svg
												xmlns="http://www.w3.org/2000/svg"
												viewBox="0 0 16 16"
												fill="currentColor"
												class="size-3"
											>
												<path
													fill-rule="evenodd"
													d="M8.914 6.025a.75.75 0 0 1 1.06 0 3.5 3.5 0 0 1 0 4.95l-2 2a3.5 3.5 0 0 1-5.396-4.402.75.75 0 0 1 1.251.827 2 2 0 0 0 3.085 2.514l2-2a2 2 0 0 0 0-2.828.75.75 0 0 1 0-1.06Z"
													clip-rule="evenodd"
												/>
												<path
													fill-rule="evenodd"
													d="M7.086 9.975a.75.75 0 0 1-1.06 0 3.5 3.5 0 0 1 0-4.95l2-2a3.5 3.5 0 0 1 5.396 4.402.75.75 0 0 1-1.251-.827 2 2 0 0 0-3.085-2.514l-2 2a2 2 0 0 0 0 2.828.75.75 0 0 1 0 1.06Z"
													clip-rule="evenodd"
												/>
											</svg>
										</div>
									</Tooltip>
								{/if}

								{#if item.model?.info?.meta?.description}
									<Tooltip
Timothy J. Baek's avatar
Timothy J. Baek committed
371
372
373
374
375
376
										content={`${marked.parse(
											sanitizeResponseContent(item.model?.info?.meta?.description).replaceAll(
												'\n',
												'<br>'
											)
										)}`}
377
									>
Timothy J. Baek's avatar
Timothy J. Baek committed
378
										<div class=" translate-y-[1px]">
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
											<svg
												xmlns="http://www.w3.org/2000/svg"
												fill="none"
												viewBox="0 0 24 24"
												stroke-width="1.5"
												stroke="currentColor"
												class="w-4 h-4"
											>
												<path
													stroke-linecap="round"
													stroke-linejoin="round"
													d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
												/>
											</svg>
										</div>
									</Tooltip>
								{/if}
Timothy J. Baek's avatar
Timothy J. Baek committed
396
397
398
399
400
401
402
403
404
405
406
407
408
409

								{#if !$mobile && (item?.model?.info?.meta?.tags ?? []).length > 0}
									<div class="flex gap-0.5 self-center items-center h-full translate-y-[0.5px]">
										{#each item.model?.info?.meta.tags as tag}
											<Tooltip content={tag.name}>
												<div
													class=" text-xs font-bold px-1 rounded uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
												>
													{tag.name}
												</div>
											</Tooltip>
										{/each}
									</div>
								{/if}
410
							</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
411
						</div>
412

413
						{#if value === item.value}
414
							<div class="ml-auto pl-2">
415
416
417
								<Check />
							</div>
						{/if}
Timothy J. Baek's avatar
Timothy J. Baek committed
418
					</button>
419
420
421
				{:else}
					<div>
						<div class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-100">
422
							{$i18n.t('No results found')}
423
424
425
426
						</div>
					</div>
				{/each}

427
428
				{#if !(searchValue.trim() in $MODEL_DOWNLOAD_POOL) && searchValue && ollamaVersion && $user.role === 'admin'}
					<button
Timothy J. Baek's avatar
Timothy J. Baek committed
429
						class="flex w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-none transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-[highlighted]:bg-muted"
430
431
432
433
						on:click={() => {
							pullModelHandler();
						}}
					>
434
						{$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, { searchValue: searchValue })}
435
436
437
					</button>
				{/if}

438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
				{#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
					<div
						class="flex w-full justify-between font-medium select-none rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-none transition-all duration-75 rounded-lg cursor-pointer data-[highlighted]:bg-muted"
					>
						<div class="flex">
							<div class="-ml-2 mr-2.5 translate-y-0.5">
								<svg
									class="size-4"
									viewBox="0 0 24 24"
									fill="currentColor"
									xmlns="http://www.w3.org/2000/svg"
									><style>
										.spinner_ajPY {
											transform-origin: center;
											animation: spinner_AtaB 0.75s infinite linear;
										}
										@keyframes spinner_AtaB {
											100% {
												transform: rotate(360deg);
											}
										}
									</style><path
										d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
										opacity=".25"
									/><path
										d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
										class="spinner_ajPY"
									/></svg
								>
							</div>

							<div class="flex flex-col self-start">
								<div class="line-clamp-1">
									Downloading "{model}" {'pullProgress' in $MODEL_DOWNLOAD_POOL[model]
										? `(${$MODEL_DOWNLOAD_POOL[model].pullProgress}%)`
										: ''}
								</div>

								{#if 'digest' in $MODEL_DOWNLOAD_POOL[model] && $MODEL_DOWNLOAD_POOL[model].digest}
									<div class="-mt-1 h-fit text-[0.7rem] dark:text-gray-500 line-clamp-1">
										{$MODEL_DOWNLOAD_POOL[model].digest}
									</div>
								{/if}
							</div>
						</div>

484
						<div class="mr-2 translate-y-0.5">
485
							<Tooltip content={$i18n.t('Cancel')}>
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
								<button
									class="text-gray-800 dark:text-gray-100"
									on:click={() => {
										cancelModelPullHandler(model);
									}}
								>
									<svg
										class="w-4 h-4 text-gray-800 dark:text-white"
										aria-hidden="true"
										xmlns="http://www.w3.org/2000/svg"
										width="24"
										height="24"
										fill="currentColor"
										viewBox="0 0 24 24"
									>
										<path
											stroke="currentColor"
											stroke-linecap="round"
											stroke-linejoin="round"
											stroke-width="2"
											d="M6 18 17.94 6M18 18 6.06 6"
										/>
									</svg>
								</button>
							</Tooltip>
						</div>
					</div>
				{/each}
			</div>
Timothy J. Baek's avatar
Timothy J. Baek committed
515
516
517

			<div class="hidden w-[42rem]" />
			<div class="hidden w-[32rem]" />
518
		</slot>
519
520
521
522
	</DropdownMenu.Content>
</DropdownMenu.Root>

<style>
Timothy J. Baek's avatar
Timothy J. Baek committed
523
524
525
	.scrollbar-hidden:active::-webkit-scrollbar-thumb,
	.scrollbar-hidden:focus::-webkit-scrollbar-thumb,
	.scrollbar-hidden:hover::-webkit-scrollbar-thumb {
526
527
		visibility: visible;
	}
Timothy J. Baek's avatar
Timothy J. Baek committed
528
	.scrollbar-hidden::-webkit-scrollbar-thumb {
529
530
531
		visibility: hidden;
	}
</style>