Selector.svelte 18 KB

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