Selector.svelte 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  1. <script lang="ts">
  2. import { DropdownMenu } from 'bits-ui';
  3. import { marked } from 'marked';
  4. import Fuse from 'fuse.js';
  5. import dayjs from '$lib/dayjs';
  6. import relativeTime from 'dayjs/plugin/relativeTime';
  7. dayjs.extend(relativeTime);
  8. import Spinner from '$lib/components/common/Spinner.svelte';
  9. import { flyAndScale } from '$lib/utils/transitions';
  10. import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
  11. import { goto } from '$app/navigation';
  12. import { deleteModel, getOllamaVersion, pullModel, unloadModel } from '$lib/apis/ollama';
  13. import {
  14. user,
  15. MODEL_DOWNLOAD_POOL,
  16. models,
  17. mobile,
  18. temporaryChatEnabled,
  19. settings,
  20. config
  21. } from '$lib/stores';
  22. import { toast } from 'svelte-sonner';
  23. import { capitalizeFirstLetter, sanitizeResponseContent, splitStream } from '$lib/utils';
  24. import { getModels } from '$lib/apis';
  25. import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
  26. import Check from '$lib/components/icons/Check.svelte';
  27. import Search from '$lib/components/icons/Search.svelte';
  28. import Tooltip from '$lib/components/common/Tooltip.svelte';
  29. import Switch from '$lib/components/common/Switch.svelte';
  30. import ChatBubbleOval from '$lib/components/icons/ChatBubbleOval.svelte';
  31. import ModelItem from './ModelItem.svelte';
  32. const i18n = getContext('i18n');
  33. const dispatch = createEventDispatcher();
  34. export let id = '';
  35. export let value = '';
  36. export let placeholder = 'Select a model';
  37. export let searchEnabled = true;
  38. export let searchPlaceholder = $i18n.t('Search a model');
  39. export let showTemporaryChatControl = false;
  40. export let items: {
  41. label: string;
  42. value: string;
  43. model: Model;
  44. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  45. [key: string]: any;
  46. }[] = [];
  47. export let className = 'w-[32rem]';
  48. export let triggerClassName = 'text-lg';
  49. export let pinModelHandler: (modelId: string) => void = () => {};
  50. let tagsContainerElement;
  51. let show = false;
  52. let tags = [];
  53. let selectedModel = '';
  54. $: selectedModel = items.find((item) => item.value === value) ?? '';
  55. let searchValue = '';
  56. let selectedTag = '';
  57. let selectedConnectionType = '';
  58. let ollamaVersion = null;
  59. let selectedModelIdx = 0;
  60. const fuse = new Fuse(
  61. items.map((item) => {
  62. const _item = {
  63. ...item,
  64. modelName: item.model?.name,
  65. tags: (item.model?.tags ?? []).map((tag) => tag.name).join(' '),
  66. desc: item.model?.info?.meta?.description
  67. };
  68. return _item;
  69. }),
  70. {
  71. keys: ['value', 'tags', 'modelName'],
  72. threshold: 0.4
  73. }
  74. );
  75. $: filteredItems = (
  76. searchValue
  77. ? fuse
  78. .search(searchValue)
  79. .map((e) => {
  80. return e.item;
  81. })
  82. .filter((item) => {
  83. if (selectedTag === '') {
  84. return true;
  85. }
  86. return (item.model?.tags ?? []).map((tag) => tag.name).includes(selectedTag);
  87. })
  88. .filter((item) => {
  89. if (selectedConnectionType === '') {
  90. return true;
  91. } else if (selectedConnectionType === 'local') {
  92. return item.model?.connection_type === 'local';
  93. } else if (selectedConnectionType === 'external') {
  94. return item.model?.connection_type === 'external';
  95. } else if (selectedConnectionType === 'direct') {
  96. return item.model?.direct;
  97. }
  98. })
  99. : items
  100. .filter((item) => {
  101. if (selectedTag === '') {
  102. return true;
  103. }
  104. return (item.model?.tags ?? []).map((tag) => tag.name).includes(selectedTag);
  105. })
  106. .filter((item) => {
  107. if (selectedConnectionType === '') {
  108. return true;
  109. } else if (selectedConnectionType === 'local') {
  110. return item.model?.connection_type === 'local';
  111. } else if (selectedConnectionType === 'external') {
  112. return item.model?.connection_type === 'external';
  113. } else if (selectedConnectionType === 'direct') {
  114. return item.model?.direct;
  115. }
  116. })
  117. ).filter((item) => !(item.model?.info?.meta?.hidden ?? false));
  118. $: if (selectedTag || selectedConnectionType) {
  119. resetView();
  120. } else {
  121. resetView();
  122. }
  123. const resetView = async () => {
  124. await tick();
  125. const selectedInFiltered = filteredItems.findIndex((item) => item.value === value);
  126. if (selectedInFiltered >= 0) {
  127. // The selected model is visible in the current filter
  128. selectedModelIdx = selectedInFiltered;
  129. } else {
  130. // The selected model is not visible, default to first item in filtered list
  131. selectedModelIdx = 0;
  132. }
  133. await tick();
  134. const item = document.querySelector(`[data-arrow-selected="true"]`);
  135. item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
  136. };
  137. const pullModelHandler = async () => {
  138. const sanitizedModelTag = searchValue.trim().replace(/^ollama\s+(run|pull)\s+/, '');
  139. console.log($MODEL_DOWNLOAD_POOL);
  140. if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag]) {
  141. toast.error(
  142. $i18n.t(`Model '{{modelTag}}' is already in queue for downloading.`, {
  143. modelTag: sanitizedModelTag
  144. })
  145. );
  146. return;
  147. }
  148. if (Object.keys($MODEL_DOWNLOAD_POOL).length === 3) {
  149. toast.error(
  150. $i18n.t('Maximum of 3 models can be downloaded simultaneously. Please try again later.')
  151. );
  152. return;
  153. }
  154. const [res, controller] = await pullModel(localStorage.token, sanitizedModelTag, '0').catch(
  155. (error) => {
  156. toast.error(`${error}`);
  157. return null;
  158. }
  159. );
  160. if (res) {
  161. const reader = res.body
  162. .pipeThrough(new TextDecoderStream())
  163. .pipeThrough(splitStream('\n'))
  164. .getReader();
  165. MODEL_DOWNLOAD_POOL.set({
  166. ...$MODEL_DOWNLOAD_POOL,
  167. [sanitizedModelTag]: {
  168. ...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
  169. abortController: controller,
  170. reader,
  171. done: false
  172. }
  173. });
  174. while (true) {
  175. try {
  176. const { value, done } = await reader.read();
  177. if (done) break;
  178. let lines = value.split('\n');
  179. for (const line of lines) {
  180. if (line !== '') {
  181. let data = JSON.parse(line);
  182. console.log(data);
  183. if (data.error) {
  184. throw data.error;
  185. }
  186. if (data.detail) {
  187. throw data.detail;
  188. }
  189. if (data.status) {
  190. if (data.digest) {
  191. let downloadProgress = 0;
  192. if (data.completed) {
  193. downloadProgress = Math.round((data.completed / data.total) * 1000) / 10;
  194. } else {
  195. downloadProgress = 100;
  196. }
  197. MODEL_DOWNLOAD_POOL.set({
  198. ...$MODEL_DOWNLOAD_POOL,
  199. [sanitizedModelTag]: {
  200. ...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
  201. pullProgress: downloadProgress,
  202. digest: data.digest
  203. }
  204. });
  205. } else {
  206. toast.success(data.status);
  207. MODEL_DOWNLOAD_POOL.set({
  208. ...$MODEL_DOWNLOAD_POOL,
  209. [sanitizedModelTag]: {
  210. ...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
  211. done: data.status === 'success'
  212. }
  213. });
  214. }
  215. }
  216. }
  217. }
  218. } catch (error) {
  219. console.log(error);
  220. if (typeof error !== 'string') {
  221. error = error.message;
  222. }
  223. toast.error(`${error}`);
  224. // opts.callback({ success: false, error, modelName: opts.modelName });
  225. break;
  226. }
  227. }
  228. if ($MODEL_DOWNLOAD_POOL[sanitizedModelTag].done) {
  229. toast.success(
  230. $i18n.t(`Model '{{modelName}}' has been successfully downloaded.`, {
  231. modelName: sanitizedModelTag
  232. })
  233. );
  234. models.set(
  235. await getModels(
  236. localStorage.token,
  237. $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
  238. )
  239. );
  240. } else {
  241. toast.error($i18n.t('Download canceled'));
  242. }
  243. delete $MODEL_DOWNLOAD_POOL[sanitizedModelTag];
  244. MODEL_DOWNLOAD_POOL.set({
  245. ...$MODEL_DOWNLOAD_POOL
  246. });
  247. }
  248. };
  249. onMount(async () => {
  250. ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false);
  251. if (items) {
  252. tags = items
  253. .filter((item) => !(item.model?.info?.meta?.hidden ?? false))
  254. .flatMap((item) => item.model?.tags ?? [])
  255. .map((tag) => tag.name);
  256. // Remove duplicates and sort
  257. tags = Array.from(new Set(tags)).sort((a, b) => a.localeCompare(b));
  258. }
  259. });
  260. const cancelModelPullHandler = async (model: string) => {
  261. const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model];
  262. if (abortController) {
  263. abortController.abort();
  264. }
  265. if (reader) {
  266. await reader.cancel();
  267. delete $MODEL_DOWNLOAD_POOL[model];
  268. MODEL_DOWNLOAD_POOL.set({
  269. ...$MODEL_DOWNLOAD_POOL
  270. });
  271. await deleteModel(localStorage.token, model);
  272. toast.success(`${model} download has been canceled`);
  273. }
  274. };
  275. const unloadModelHandler = async (model: string) => {
  276. const res = await unloadModel(localStorage.token, model).catch((error) => {
  277. toast.error($i18n.t('Error unloading model: {{error}}', { error }));
  278. });
  279. if (res) {
  280. toast.success($i18n.t('Model unloaded successfully'));
  281. models.set(
  282. await getModels(
  283. localStorage.token,
  284. $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
  285. )
  286. );
  287. }
  288. };
  289. </script>
  290. <DropdownMenu.Root
  291. bind:open={show}
  292. onOpenChange={async () => {
  293. searchValue = '';
  294. window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0);
  295. resetView();
  296. }}
  297. closeFocus={false}
  298. >
  299. <DropdownMenu.Trigger
  300. class="relative w-full font-primary"
  301. aria-label={placeholder}
  302. id="model-selector-{id}-button"
  303. >
  304. <button
  305. class="flex w-full text-left px-0.5 outline-hidden bg-transparent truncate {triggerClassName} justify-between font-medium placeholder-gray-400 focus:outline-hidden"
  306. on:mouseenter={async () => {
  307. models.set(
  308. await getModels(
  309. localStorage.token,
  310. $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
  311. )
  312. );
  313. }}
  314. type="button"
  315. >
  316. {#if selectedModel}
  317. {selectedModel.label}
  318. {:else}
  319. {placeholder}
  320. {/if}
  321. <ChevronDown className=" self-center ml-2 size-3" strokeWidth="2.5" />
  322. </button>
  323. </DropdownMenu.Trigger>
  324. <DropdownMenu.Content
  325. class=" z-40 {$mobile
  326. ? `w-full`
  327. : `${className}`} max-w-[calc(100vw-1rem)] justify-start rounded-xl bg-white dark:bg-gray-850 dark:text-white shadow-lg outline-hidden"
  328. transition={flyAndScale}
  329. side={$mobile ? 'bottom' : 'bottom-start'}
  330. sideOffset={3}
  331. >
  332. <slot>
  333. {#if searchEnabled}
  334. <div class="flex items-center gap-2.5 px-5 mt-3.5 mb-1.5">
  335. <Search className="size-4" strokeWidth="2.5" />
  336. <input
  337. id="model-search-input"
  338. bind:value={searchValue}
  339. class="w-full text-sm bg-transparent outline-hidden"
  340. placeholder={searchPlaceholder}
  341. autocomplete="off"
  342. on:keydown={(e) => {
  343. if (e.code === 'Enter' && filteredItems.length > 0) {
  344. value = filteredItems[selectedModelIdx].value;
  345. show = false;
  346. return; // dont need to scroll on selection
  347. } else if (e.code === 'ArrowDown') {
  348. selectedModelIdx = Math.min(selectedModelIdx + 1, filteredItems.length - 1);
  349. } else if (e.code === 'ArrowUp') {
  350. selectedModelIdx = Math.max(selectedModelIdx - 1, 0);
  351. } else {
  352. // if the user types something, reset to the top selection.
  353. selectedModelIdx = 0;
  354. }
  355. const item = document.querySelector(`[data-arrow-selected="true"]`);
  356. item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
  357. }}
  358. />
  359. </div>
  360. {/if}
  361. <div class="px-3">
  362. {#if tags && items.filter((item) => !(item.model?.info?.meta?.hidden ?? false)).length > 0}
  363. <div
  364. class=" flex w-full bg-white dark:bg-gray-850 overflow-x-auto scrollbar-none"
  365. on:wheel={(e) => {
  366. if (e.deltaY !== 0) {
  367. e.preventDefault();
  368. e.currentTarget.scrollLeft += e.deltaY;
  369. }
  370. }}
  371. >
  372. <div
  373. class="flex gap-1 w-fit text-center text-sm font-medium rounded-full bg-transparent px-1.5 pb-0.5"
  374. bind:this={tagsContainerElement}
  375. >
  376. {#if items.find((item) => item.model?.connection_type === 'local') || items.find((item) => item.model?.connection_type === 'external') || items.find((item) => item.model?.direct) || tags.length > 0}
  377. <button
  378. class="min-w-fit outline-none p-1.5 {selectedTag === '' &&
  379. selectedConnectionType === ''
  380. ? ''
  381. : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
  382. on:click={() => {
  383. selectedConnectionType = '';
  384. selectedTag = '';
  385. }}
  386. >
  387. {$i18n.t('All')}
  388. </button>
  389. {/if}
  390. {#if items.find((item) => item.model?.connection_type === 'local')}
  391. <button
  392. class="min-w-fit outline-none p-1.5 {selectedConnectionType === 'local'
  393. ? ''
  394. : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
  395. on:click={() => {
  396. selectedTag = '';
  397. selectedConnectionType = 'local';
  398. }}
  399. >
  400. {$i18n.t('Local')}
  401. </button>
  402. {/if}
  403. {#if items.find((item) => item.model?.connection_type === 'external')}
  404. <button
  405. class="min-w-fit outline-none p-1.5 {selectedConnectionType === 'external'
  406. ? ''
  407. : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
  408. on:click={() => {
  409. selectedTag = '';
  410. selectedConnectionType = 'external';
  411. }}
  412. >
  413. {$i18n.t('External')}
  414. </button>
  415. {/if}
  416. {#if items.find((item) => item.model?.direct)}
  417. <button
  418. class="min-w-fit outline-none p-1.5 {selectedConnectionType === 'direct'
  419. ? ''
  420. : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
  421. on:click={() => {
  422. selectedTag = '';
  423. selectedConnectionType = 'direct';
  424. }}
  425. >
  426. {$i18n.t('Direct')}
  427. </button>
  428. {/if}
  429. {#each tags as tag}
  430. <button
  431. class="min-w-fit outline-none p-1.5 {selectedTag === tag
  432. ? ''
  433. : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
  434. on:click={() => {
  435. selectedConnectionType = '';
  436. selectedTag = tag;
  437. }}
  438. >
  439. {tag}
  440. </button>
  441. {/each}
  442. </div>
  443. </div>
  444. {/if}
  445. </div>
  446. <div class="px-3 max-h-64 overflow-y-auto group relative">
  447. {#each filteredItems as item, index}
  448. <ModelItem
  449. {selectedModelIdx}
  450. {item}
  451. {index}
  452. {value}
  453. {pinModelHandler}
  454. {unloadModelHandler}
  455. onClick={() => {
  456. value = item.value;
  457. selectedModelIdx = index;
  458. show = false;
  459. }}
  460. />
  461. {:else}
  462. <div class="">
  463. <div class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-100">
  464. {$i18n.t('No results found')}
  465. </div>
  466. </div>
  467. {/each}
  468. {#if !(searchValue.trim() in $MODEL_DOWNLOAD_POOL) && searchValue && ollamaVersion && $user?.role === 'admin'}
  469. <Tooltip
  470. content={$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, {
  471. searchValue: searchValue
  472. })}
  473. placement="top-start"
  474. >
  475. <button
  476. 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-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-highlighted:bg-muted"
  477. on:click={() => {
  478. pullModelHandler();
  479. }}
  480. >
  481. <div class=" truncate">
  482. {$i18n.t(`Pull "{{searchValue}}" from Ollama.com`, { searchValue: searchValue })}
  483. </div>
  484. </button>
  485. </Tooltip>
  486. {/if}
  487. {#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
  488. <div
  489. 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-hidden transition-all duration-75 rounded-lg cursor-pointer data-highlighted:bg-muted"
  490. >
  491. <div class="flex">
  492. <div class="-ml-2 mr-2.5 translate-y-0.5">
  493. <Spinner />
  494. </div>
  495. <div class="flex flex-col self-start">
  496. <div class="flex gap-1">
  497. <div class="line-clamp-1">
  498. Downloading "{model}"
  499. </div>
  500. <div class="shrink-0">
  501. {'pullProgress' in $MODEL_DOWNLOAD_POOL[model]
  502. ? `(${$MODEL_DOWNLOAD_POOL[model].pullProgress}%)`
  503. : ''}
  504. </div>
  505. </div>
  506. {#if 'digest' in $MODEL_DOWNLOAD_POOL[model] && $MODEL_DOWNLOAD_POOL[model].digest}
  507. <div class="-mt-1 h-fit text-[0.7rem] dark:text-gray-500 line-clamp-1">
  508. {$MODEL_DOWNLOAD_POOL[model].digest}
  509. </div>
  510. {/if}
  511. </div>
  512. </div>
  513. <div class="mr-2 ml-1 translate-y-0.5">
  514. <Tooltip content={$i18n.t('Cancel')}>
  515. <button
  516. class="text-gray-800 dark:text-gray-100"
  517. on:click={() => {
  518. cancelModelPullHandler(model);
  519. }}
  520. >
  521. <svg
  522. class="w-4 h-4 text-gray-800 dark:text-white"
  523. aria-hidden="true"
  524. xmlns="http://www.w3.org/2000/svg"
  525. width="24"
  526. height="24"
  527. fill="currentColor"
  528. viewBox="0 0 24 24"
  529. >
  530. <path
  531. stroke="currentColor"
  532. stroke-linecap="round"
  533. stroke-linejoin="round"
  534. stroke-width="2"
  535. d="M6 18 17.94 6M18 18 6.06 6"
  536. />
  537. </svg>
  538. </button>
  539. </Tooltip>
  540. </div>
  541. </div>
  542. {/each}
  543. </div>
  544. {#if showTemporaryChatControl}
  545. <div class="flex items-center mx-2 mt-1 mb-2">
  546. <button
  547. class="flex justify-between w-full font-medium line-clamp-1 select-none items-center rounded-button py-2 px-3 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-highlighted:bg-muted"
  548. on:click={async () => {
  549. temporaryChatEnabled.set(!$temporaryChatEnabled);
  550. await goto('/');
  551. const newChatButton = document.getElementById('new-chat-button');
  552. setTimeout(() => {
  553. newChatButton?.click();
  554. }, 0);
  555. // add 'temporary-chat=true' to the URL
  556. if ($temporaryChatEnabled) {
  557. history.replaceState(null, '', '?temporary-chat=true');
  558. } else {
  559. history.replaceState(null, '', location.pathname);
  560. }
  561. show = false;
  562. }}
  563. >
  564. <div class="flex gap-2.5 items-center">
  565. <ChatBubbleOval className="size-4" strokeWidth="2.5" />
  566. {$i18n.t(`Temporary Chat`)}
  567. </div>
  568. <div>
  569. <Switch state={$temporaryChatEnabled} />
  570. </div>
  571. </button>
  572. </div>
  573. {:else}
  574. <div class="mb-3"></div>
  575. {/if}
  576. <div class="hidden w-[42rem]" />
  577. <div class="hidden w-[32rem]" />
  578. </slot>
  579. </DropdownMenu.Content>
  580. </DropdownMenu.Root>