Selector.svelte 19 KB

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