Models.svelte 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. <script lang="ts">
  2. import { marked } from 'marked';
  3. import { toast } from 'svelte-sonner';
  4. import Sortable from 'sortablejs';
  5. import fileSaver from 'file-saver';
  6. const { saveAs } = fileSaver;
  7. import { onMount, getContext, tick } from 'svelte';
  8. import { goto } from '$app/navigation';
  9. const i18n = getContext('i18n');
  10. import { WEBUI_NAME, config, mobile, models as _models, settings, user } from '$lib/stores';
  11. import { WEBUI_BASE_URL } from '$lib/constants';
  12. import {
  13. createNewModel,
  14. deleteModelById,
  15. getModels as getWorkspaceModels,
  16. toggleModelById,
  17. updateModelById
  18. } from '$lib/apis/models';
  19. import { getModels } from '$lib/apis';
  20. import { getGroups } from '$lib/apis/groups';
  21. import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
  22. import ModelMenu from './Models/ModelMenu.svelte';
  23. import ModelDeleteConfirmDialog from '../common/ConfirmDialog.svelte';
  24. import Tooltip from '../common/Tooltip.svelte';
  25. import GarbageBin from '../icons/GarbageBin.svelte';
  26. import Search from '../icons/Search.svelte';
  27. import Plus from '../icons/Plus.svelte';
  28. import ChevronRight from '../icons/ChevronRight.svelte';
  29. import Switch from '../common/Switch.svelte';
  30. import Spinner from '../common/Spinner.svelte';
  31. import { capitalizeFirstLetter, copyToClipboard } from '$lib/utils';
  32. import XMark from '../icons/XMark.svelte';
  33. import EyeSlash from '../icons/EyeSlash.svelte';
  34. import Eye from '../icons/Eye.svelte';
  35. let shiftKey = false;
  36. let importFiles;
  37. let modelsImportInputElement: HTMLInputElement;
  38. let tagsContainerElement: HTMLDivElement;
  39. let loaded = false;
  40. let models = [];
  41. let tags = [];
  42. let selectedTag = '';
  43. let filteredModels = [];
  44. let selectedModel = null;
  45. let showModelDeleteConfirm = false;
  46. let group_ids = [];
  47. $: if (models) {
  48. filteredModels = models.filter((m) => {
  49. if (query === '' && selectedTag === '') return true;
  50. const lowerQuery = query.toLowerCase();
  51. return (
  52. ((m.name || '').toLowerCase().includes(lowerQuery) ||
  53. (m.user?.name || '').toLowerCase().includes(lowerQuery) || // Search by user name
  54. (m.user?.email || '').toLowerCase().includes(lowerQuery)) && // Search by user email
  55. (selectedTag === '' ||
  56. m?.meta?.tags?.some((tag) => tag.name.toLowerCase() === selectedTag.toLowerCase()))
  57. );
  58. });
  59. }
  60. let query = '';
  61. const deleteModelHandler = async (model) => {
  62. const res = await deleteModelById(localStorage.token, model.id).catch((e) => {
  63. toast.error(`${e}`);
  64. return null;
  65. });
  66. if (res) {
  67. toast.success($i18n.t(`Deleted {{name}}`, { name: model.id }));
  68. }
  69. await _models.set(
  70. await getModels(
  71. localStorage.token,
  72. $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
  73. )
  74. );
  75. models = await getWorkspaceModels(localStorage.token);
  76. };
  77. const cloneModelHandler = async (model) => {
  78. sessionStorage.model = JSON.stringify({
  79. ...model,
  80. id: `${model.id}-clone`,
  81. name: `${model.name} (Clone)`
  82. });
  83. goto('/workspace/models/create');
  84. };
  85. const shareModelHandler = async (model) => {
  86. toast.success($i18n.t('Redirecting you to Open WebUI Community'));
  87. const url = 'https://openwebui.com';
  88. const tab = await window.open(`${url}/models/create`, '_blank');
  89. const messageHandler = (event) => {
  90. if (event.origin !== url) return;
  91. if (event.data === 'loaded') {
  92. tab.postMessage(JSON.stringify(model), '*');
  93. window.removeEventListener('message', messageHandler);
  94. }
  95. };
  96. window.addEventListener('message', messageHandler, false);
  97. };
  98. const hideModelHandler = async (model) => {
  99. model.meta = {
  100. ...model.meta,
  101. hidden: !(model?.meta?.hidden ?? false)
  102. };
  103. console.log(model);
  104. const res = await updateModelById(localStorage.token, model.id, model);
  105. if (res) {
  106. toast.success(
  107. $i18n.t(`Model {{name}} is now {{status}}`, {
  108. name: model.id,
  109. status: model.meta.hidden ? 'hidden' : 'visible'
  110. })
  111. );
  112. }
  113. await _models.set(
  114. await getModels(
  115. localStorage.token,
  116. $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
  117. )
  118. );
  119. models = await getWorkspaceModels(localStorage.token);
  120. };
  121. const copyLinkHandler = async (model) => {
  122. const baseUrl = window.location.origin;
  123. const res = await copyToClipboard(`${baseUrl}/?model=${encodeURIComponent(model.id)}`);
  124. if (res) {
  125. toast.success($i18n.t('Copied link to clipboard'));
  126. } else {
  127. toast.error($i18n.t('Failed to copy link'));
  128. }
  129. };
  130. const downloadModels = async (models) => {
  131. let blob = new Blob([JSON.stringify(models)], {
  132. type: 'application/json'
  133. });
  134. saveAs(blob, `models-export-${Date.now()}.json`);
  135. };
  136. const exportModelHandler = async (model) => {
  137. let blob = new Blob([JSON.stringify([model])], {
  138. type: 'application/json'
  139. });
  140. saveAs(blob, `${model.id}-${Date.now()}.json`);
  141. };
  142. onMount(async () => {
  143. models = await getWorkspaceModels(localStorage.token);
  144. let groups = await getGroups(localStorage.token);
  145. group_ids = groups.map((group) => group.id);
  146. if (models) {
  147. tags = models
  148. .filter((model) => !(model?.meta?.hidden ?? false))
  149. .flatMap((model) => model?.meta?.tags ?? [])
  150. .map((tag) => tag.name);
  151. // Remove duplicates and sort
  152. tags = Array.from(new Set(tags)).sort((a, b) => a.localeCompare(b));
  153. }
  154. loaded = true;
  155. const onKeyDown = (event) => {
  156. if (event.key === 'Shift') {
  157. shiftKey = true;
  158. }
  159. };
  160. const onKeyUp = (event) => {
  161. if (event.key === 'Shift') {
  162. shiftKey = false;
  163. }
  164. };
  165. const onBlur = () => {
  166. shiftKey = false;
  167. };
  168. window.addEventListener('keydown', onKeyDown);
  169. window.addEventListener('keyup', onKeyUp);
  170. window.addEventListener('blur-sm', onBlur);
  171. return () => {
  172. window.removeEventListener('keydown', onKeyDown);
  173. window.removeEventListener('keyup', onKeyUp);
  174. window.removeEventListener('blur-sm', onBlur);
  175. };
  176. });
  177. </script>
  178. <svelte:head>
  179. <title>
  180. {$i18n.t('Models')} • {$WEBUI_NAME}
  181. </title>
  182. </svelte:head>
  183. {#if loaded}
  184. <ModelDeleteConfirmDialog
  185. bind:show={showModelDeleteConfirm}
  186. on:confirm={() => {
  187. deleteModelHandler(selectedModel);
  188. }}
  189. />
  190. <div class="flex flex-col gap-1 mt-1.5">
  191. <div class="flex justify-between items-center">
  192. <div class="flex items-center md:self-center text-xl font-medium px-0.5">
  193. {$i18n.t('Models')}
  194. <div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
  195. <span class="text-lg font-medium text-gray-500 dark:text-gray-300"
  196. >{filteredModels.length}</span
  197. >
  198. </div>
  199. </div>
  200. <div class=" flex flex-1 items-center w-full space-x-2">
  201. <div class="flex flex-1 items-center">
  202. <div class=" self-center ml-1 mr-3">
  203. <Search className="size-3.5" />
  204. </div>
  205. <input
  206. class=" w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
  207. bind:value={query}
  208. placeholder={$i18n.t('Search Models')}
  209. />
  210. {#if query}
  211. <div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
  212. <button
  213. class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
  214. on:click={() => {
  215. query = '';
  216. }}
  217. >
  218. <XMark className="size-3" strokeWidth="2" />
  219. </button>
  220. </div>
  221. {/if}
  222. </div>
  223. <div>
  224. <a
  225. class=" px-2 py-2 rounded-xl hover:bg-gray-700/10 dark:hover:bg-gray-100/10 dark:text-gray-300 dark:hover:text-white transition font-medium text-sm flex items-center space-x-1"
  226. href="/workspace/models/create"
  227. >
  228. <Plus className="size-3.5" />
  229. </a>
  230. </div>
  231. </div>
  232. </div>
  233. {#if tags.length > 0}
  234. <div
  235. class=" flex w-full bg-transparent overflow-x-auto scrollbar-none"
  236. on:wheel={(e) => {
  237. if (e.deltaY !== 0) {
  238. e.preventDefault();
  239. e.currentTarget.scrollLeft += e.deltaY;
  240. }
  241. }}
  242. >
  243. <div
  244. class="flex gap-1 w-fit text-center text-sm font-medium rounded-full"
  245. bind:this={tagsContainerElement}
  246. >
  247. <button
  248. class="min-w-fit outline-none p-1.5 {selectedTag === ''
  249. ? ''
  250. : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
  251. on:click={() => {
  252. selectedTag = '';
  253. }}
  254. >
  255. {$i18n.t('All')}
  256. </button>
  257. {#each tags as tag}
  258. <Tooltip content={tag}>
  259. <button
  260. class="min-w-fit outline-none p-1.5 {selectedTag === tag
  261. ? ''
  262. : 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition capitalize"
  263. on:click={() => {
  264. selectedTag = tag;
  265. }}
  266. >
  267. {tag.length > 32 ? `${tag.slice(0, 32)}...` : tag}
  268. </button>
  269. </Tooltip>
  270. {/each}
  271. </div>
  272. </div>
  273. {/if}
  274. <div class=" my-2 mb-5 gap-2 grid lg:grid-cols-2 xl:grid-cols-3" id="model-list">
  275. {#each filteredModels as model (model.id)}
  276. <div
  277. class=" flex flex-col cursor-pointer w-full px-4 py-3 border border-gray-50 dark:border-gray-850 dark:hover:bg-white/5 hover:bg-black/5 rounded-2xl transition"
  278. id="model-item-{model.id}"
  279. >
  280. <div class="flex gap-4 mt-1 mb-0.5">
  281. <div class=" w-10">
  282. <div
  283. class=" rounded-full object-cover {model.is_active
  284. ? ''
  285. : 'opacity-50 dark:opacity-50'} "
  286. >
  287. <img
  288. src={model?.meta?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
  289. alt="modelfile profile"
  290. class=" rounded-full w-full h-auto object-cover"
  291. />
  292. </div>
  293. </div>
  294. <a
  295. class=" flex flex-1 cursor-pointer w-full"
  296. href={`/?models=${encodeURIComponent(model.id)}`}
  297. >
  298. <div class=" flex-1 self-center {model.is_active ? '' : 'text-gray-500'}">
  299. <Tooltip
  300. content={marked.parse(model?.meta?.description ?? model.id)}
  301. className=" w-fit"
  302. placement="top-start"
  303. >
  304. <div class=" font-semibold line-clamp-1">{model.name}</div>
  305. </Tooltip>
  306. <div class="flex gap-1 text-xs overflow-hidden">
  307. <div class="line-clamp-1">
  308. {#if (model?.meta?.description ?? '').trim()}
  309. {model?.meta?.description}
  310. {:else}
  311. {model.id}
  312. {/if}
  313. </div>
  314. </div>
  315. </div>
  316. </a>
  317. </div>
  318. <div class="flex justify-between items-center -mb-0.5 px-0.5 mt-1.5">
  319. <div class=" text-xs mt-0.5">
  320. <Tooltip
  321. content={model?.user?.email ?? $i18n.t('Deleted User')}
  322. className="flex shrink-0"
  323. placement="top-start"
  324. >
  325. <div class="shrink-0 text-gray-500">
  326. {$i18n.t('By {{name}}', {
  327. name: capitalizeFirstLetter(
  328. model?.user?.name ?? model?.user?.email ?? $i18n.t('Deleted User')
  329. )
  330. })}
  331. </div>
  332. </Tooltip>
  333. </div>
  334. <div class="flex flex-row gap-0.5 items-center">
  335. {#if shiftKey}
  336. <Tooltip content={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')}>
  337. <button
  338. class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
  339. type="button"
  340. on:click={() => {
  341. hideModelHandler(model);
  342. }}
  343. >
  344. {#if model?.meta?.hidden}
  345. <EyeSlash />
  346. {:else}
  347. <Eye />
  348. {/if}
  349. </button>
  350. </Tooltip>
  351. <Tooltip content={$i18n.t('Delete')}>
  352. <button
  353. class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
  354. type="button"
  355. on:click={() => {
  356. deleteModelHandler(model);
  357. }}
  358. >
  359. <GarbageBin />
  360. </button>
  361. </Tooltip>
  362. {:else}
  363. {#if $user?.role === 'admin' || model.user_id === $user?.id || model.access_control.write.group_ids.some( (wg) => group_ids.includes(wg) )}
  364. <a
  365. class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
  366. type="button"
  367. href={`/workspace/models/edit?id=${encodeURIComponent(model.id)}`}
  368. >
  369. <svg
  370. xmlns="http://www.w3.org/2000/svg"
  371. fill="none"
  372. viewBox="0 0 24 24"
  373. stroke-width="1.5"
  374. stroke="currentColor"
  375. class="w-4 h-4"
  376. >
  377. <path
  378. stroke-linecap="round"
  379. stroke-linejoin="round"
  380. d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
  381. />
  382. </svg>
  383. </a>
  384. {/if}
  385. <ModelMenu
  386. user={$user}
  387. {model}
  388. shareHandler={() => {
  389. shareModelHandler(model);
  390. }}
  391. cloneHandler={() => {
  392. cloneModelHandler(model);
  393. }}
  394. exportHandler={() => {
  395. exportModelHandler(model);
  396. }}
  397. hideHandler={() => {
  398. hideModelHandler(model);
  399. }}
  400. copyLinkHandler={() => {
  401. copyLinkHandler(model);
  402. }}
  403. deleteHandler={() => {
  404. selectedModel = model;
  405. showModelDeleteConfirm = true;
  406. }}
  407. onClose={() => {}}
  408. >
  409. <button
  410. class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
  411. type="button"
  412. >
  413. <EllipsisHorizontal className="size-5" />
  414. </button>
  415. </ModelMenu>
  416. <div class="ml-1">
  417. <Tooltip content={model.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
  418. <Switch
  419. bind:state={model.is_active}
  420. on:change={async (e) => {
  421. toggleModelById(localStorage.token, model.id);
  422. _models.set(
  423. await getModels(
  424. localStorage.token,
  425. $config?.features?.enable_direct_connections &&
  426. ($settings?.directConnections ?? null)
  427. )
  428. );
  429. }}
  430. />
  431. </Tooltip>
  432. </div>
  433. {/if}
  434. </div>
  435. </div>
  436. </div>
  437. {/each}
  438. </div>
  439. {#if $user?.role === 'admin'}
  440. <div class=" flex justify-end w-full mb-3">
  441. <div class="flex space-x-1">
  442. <input
  443. id="models-import-input"
  444. bind:this={modelsImportInputElement}
  445. bind:files={importFiles}
  446. type="file"
  447. accept=".json"
  448. hidden
  449. on:change={() => {
  450. console.log(importFiles);
  451. let reader = new FileReader();
  452. reader.onload = async (event) => {
  453. let savedModels = JSON.parse(event.target.result);
  454. console.log(savedModels);
  455. for (const model of savedModels) {
  456. if (model?.info ?? false) {
  457. if ($_models.find((m) => m.id === model.id)) {
  458. await updateModelById(localStorage.token, model.id, model.info).catch(
  459. (error) => {
  460. return null;
  461. }
  462. );
  463. } else {
  464. await createNewModel(localStorage.token, model.info).catch((error) => {
  465. return null;
  466. });
  467. }
  468. } else {
  469. if (model?.id && model?.name) {
  470. await createNewModel(localStorage.token, model).catch((error) => {
  471. return null;
  472. });
  473. }
  474. }
  475. }
  476. await _models.set(
  477. await getModels(
  478. localStorage.token,
  479. $config?.features?.enable_direct_connections &&
  480. ($settings?.directConnections ?? null)
  481. )
  482. );
  483. models = await getWorkspaceModels(localStorage.token);
  484. };
  485. reader.readAsText(importFiles[0]);
  486. }}
  487. />
  488. <button
  489. class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
  490. on:click={() => {
  491. modelsImportInputElement.click();
  492. }}
  493. >
  494. <div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Models')}</div>
  495. <div class=" self-center">
  496. <svg
  497. xmlns="http://www.w3.org/2000/svg"
  498. viewBox="0 0 16 16"
  499. fill="currentColor"
  500. class="w-3.5 h-3.5"
  501. >
  502. <path
  503. fill-rule="evenodd"
  504. d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
  505. clip-rule="evenodd"
  506. />
  507. </svg>
  508. </div>
  509. </button>
  510. {#if models.length}
  511. <button
  512. class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
  513. on:click={async () => {
  514. downloadModels(models);
  515. }}
  516. >
  517. <div class=" self-center mr-2 font-medium line-clamp-1">
  518. {$i18n.t('Export Models')} ({models.length})
  519. </div>
  520. <div class=" self-center">
  521. <svg
  522. xmlns="http://www.w3.org/2000/svg"
  523. viewBox="0 0 16 16"
  524. fill="currentColor"
  525. class="w-3.5 h-3.5"
  526. >
  527. <path
  528. fill-rule="evenodd"
  529. d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
  530. clip-rule="evenodd"
  531. />
  532. </svg>
  533. </div>
  534. </button>
  535. {/if}
  536. </div>
  537. </div>
  538. {/if}
  539. {#if $config?.features.enable_community_sharing}
  540. <div class=" my-16">
  541. <div class=" text-xl font-medium mb-1 line-clamp-1">
  542. {$i18n.t('Made by Open WebUI Community')}
  543. </div>
  544. <a
  545. class=" flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full mb-2 px-3.5 py-1.5 rounded-xl transition"
  546. href="https://openwebui.com/models"
  547. target="_blank"
  548. >
  549. <div class=" self-center">
  550. <div class=" font-semibold line-clamp-1">{$i18n.t('Discover a model')}</div>
  551. <div class=" text-sm line-clamp-1">
  552. {$i18n.t('Discover, download, and explore model presets')}
  553. </div>
  554. </div>
  555. <div>
  556. <div>
  557. <ChevronRight />
  558. </div>
  559. </div>
  560. </a>
  561. </div>
  562. {/if}
  563. {:else}
  564. <div class="w-full h-full flex justify-center items-center">
  565. <Spinner className="size-5" />
  566. </div>
  567. {/if}