Models.svelte 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  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 {
  12. createNewModel,
  13. deleteModelById,
  14. getModels as getWorkspaceModels,
  15. toggleModelById,
  16. updateModelById
  17. } from '$lib/apis/models';
  18. import { getModels } from '$lib/apis';
  19. import { getGroups } from '$lib/apis/groups';
  20. import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
  21. import ModelMenu from './Models/ModelMenu.svelte';
  22. import ModelDeleteConfirmDialog from '../common/ConfirmDialog.svelte';
  23. import Tooltip from '../common/Tooltip.svelte';
  24. import GarbageBin from '../icons/GarbageBin.svelte';
  25. import Search from '../icons/Search.svelte';
  26. import Plus from '../icons/Plus.svelte';
  27. import ChevronRight from '../icons/ChevronRight.svelte';
  28. import Switch from '../common/Switch.svelte';
  29. import Spinner from '../common/Spinner.svelte';
  30. import { capitalizeFirstLetter } from '$lib/utils';
  31. let shiftKey = false;
  32. let importFiles;
  33. let modelsImportInputElement: HTMLInputElement;
  34. let loaded = false;
  35. let models = [];
  36. let filteredModels = [];
  37. let selectedModel = null;
  38. let showModelDeleteConfirm = false;
  39. let group_ids = [];
  40. $: if (models) {
  41. filteredModels = models.filter(
  42. (m) => searchValue === '' || m.name.toLowerCase().includes(searchValue.toLowerCase())
  43. );
  44. }
  45. let searchValue = '';
  46. const deleteModelHandler = async (model) => {
  47. const res = await deleteModelById(localStorage.token, model.id).catch((e) => {
  48. toast.error(`${e}`);
  49. return null;
  50. });
  51. if (res) {
  52. toast.success($i18n.t(`Deleted {{name}}`, { name: model.id }));
  53. }
  54. await _models.set(
  55. await getModels(
  56. localStorage.token,
  57. $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
  58. )
  59. );
  60. models = await getWorkspaceModels(localStorage.token);
  61. };
  62. const cloneModelHandler = async (model) => {
  63. sessionStorage.model = JSON.stringify({
  64. ...model,
  65. id: `${model.id}-clone`,
  66. name: `${model.name} (Clone)`
  67. });
  68. goto('/workspace/models/create');
  69. };
  70. const shareModelHandler = async (model) => {
  71. toast.success($i18n.t('Redirecting you to Open WebUI Community'));
  72. const url = 'https://openwebui.com';
  73. const tab = await window.open(`${url}/models/create`, '_blank');
  74. // Define the event handler function
  75. const messageHandler = (event) => {
  76. if (event.origin !== url) return;
  77. if (event.data === 'loaded') {
  78. tab.postMessage(JSON.stringify(model), '*');
  79. // Remove the event listener after handling the message
  80. window.removeEventListener('message', messageHandler);
  81. }
  82. };
  83. window.addEventListener('message', messageHandler, false);
  84. };
  85. const hideModelHandler = async (model) => {
  86. let info = model.info;
  87. if (!info) {
  88. info = {
  89. id: model.id,
  90. name: model.name,
  91. meta: {
  92. suggestion_prompts: null
  93. },
  94. params: {}
  95. };
  96. }
  97. info.meta = {
  98. ...info.meta,
  99. hidden: !(info?.meta?.hidden ?? false)
  100. };
  101. console.log(info);
  102. const res = await updateModelById(localStorage.token, info.id, info);
  103. if (res) {
  104. toast.success(
  105. $i18n.t(`Model {{name}} is now {{status}}`, {
  106. name: info.id,
  107. status: info.meta.hidden ? 'hidden' : 'visible'
  108. })
  109. );
  110. }
  111. await _models.set(
  112. await getModels(
  113. localStorage.token,
  114. $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
  115. )
  116. );
  117. models = await getWorkspaceModels(localStorage.token);
  118. };
  119. const downloadModels = async (models) => {
  120. let blob = new Blob([JSON.stringify(models)], {
  121. type: 'application/json'
  122. });
  123. saveAs(blob, `models-export-${Date.now()}.json`);
  124. };
  125. const exportModelHandler = async (model) => {
  126. let blob = new Blob([JSON.stringify([model])], {
  127. type: 'application/json'
  128. });
  129. saveAs(blob, `${model.id}-${Date.now()}.json`);
  130. };
  131. onMount(async () => {
  132. models = await getWorkspaceModels(localStorage.token);
  133. let groups = await getGroups(localStorage.token);
  134. group_ids = groups.map((group) => group.id);
  135. loaded = true;
  136. const onKeyDown = (event) => {
  137. if (event.key === 'Shift') {
  138. shiftKey = true;
  139. }
  140. };
  141. const onKeyUp = (event) => {
  142. if (event.key === 'Shift') {
  143. shiftKey = false;
  144. }
  145. };
  146. const onBlur = () => {
  147. shiftKey = false;
  148. };
  149. window.addEventListener('keydown', onKeyDown);
  150. window.addEventListener('keyup', onKeyUp);
  151. window.addEventListener('blur', onBlur);
  152. return () => {
  153. window.removeEventListener('keydown', onKeyDown);
  154. window.removeEventListener('keyup', onKeyUp);
  155. window.removeEventListener('blur', onBlur);
  156. };
  157. });
  158. </script>
  159. <svelte:head>
  160. <title>
  161. {$i18n.t('Models')} | {$WEBUI_NAME}
  162. </title>
  163. </svelte:head>
  164. {#if loaded}
  165. <ModelDeleteConfirmDialog
  166. bind:show={showModelDeleteConfirm}
  167. on:confirm={() => {
  168. deleteModelHandler(selectedModel);
  169. }}
  170. />
  171. <div class="flex flex-col gap-1 my-1.5">
  172. <div class="flex justify-between items-center">
  173. <div class="flex items-center md:self-center text-xl font-medium px-0.5">
  174. {$i18n.t('Models')}
  175. <div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
  176. <span class="text-lg font-medium text-gray-500 dark:text-gray-300"
  177. >{filteredModels.length}</span
  178. >
  179. </div>
  180. </div>
  181. <div class=" flex flex-1 items-center w-full space-x-2">
  182. <div class="flex flex-1 items-center">
  183. <div class=" self-center ml-1 mr-3">
  184. <Search className="size-3.5" />
  185. </div>
  186. <input
  187. class=" w-full text-sm py-1 rounded-r-xl outline-none bg-transparent"
  188. bind:value={searchValue}
  189. placeholder={$i18n.t('Search Models')}
  190. />
  191. </div>
  192. <div>
  193. <a
  194. 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"
  195. href="/workspace/models/create"
  196. >
  197. <Plus className="size-3.5" />
  198. </a>
  199. </div>
  200. </div>
  201. </div>
  202. <div class=" my-2 mb-5 gap-2 grid lg:grid-cols-2 xl:grid-cols-3" id="model-list">
  203. {#each filteredModels as model}
  204. <div
  205. class=" flex flex-col cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl transition"
  206. id="model-item-{model.id}"
  207. >
  208. <div class="flex gap-4 mt-0.5 mb-0.5">
  209. <div class=" w-[44px]">
  210. <div
  211. class=" rounded-full object-cover {model.is_active
  212. ? ''
  213. : 'opacity-50 dark:opacity-50'} "
  214. >
  215. <img
  216. src={model?.meta?.profile_image_url ?? '/static/favicon.png'}
  217. alt="modelfile profile"
  218. class=" rounded-full w-full h-auto object-cover"
  219. />
  220. </div>
  221. </div>
  222. <a
  223. class=" flex flex-1 cursor-pointer w-full"
  224. href={`/?models=${encodeURIComponent(model.id)}`}
  225. >
  226. <div class=" flex-1 self-center {model.is_active ? '' : 'text-gray-500'}">
  227. <Tooltip
  228. content={marked.parse(model?.meta?.description ?? model.id)}
  229. className=" w-fit"
  230. placement="top-start"
  231. >
  232. <div class=" font-semibold line-clamp-1">{model.name}</div>
  233. </Tooltip>
  234. <div class="flex gap-1 text-xs overflow-hidden">
  235. <div class="line-clamp-1">
  236. {#if (model?.meta?.description ?? '').trim()}
  237. {model?.meta?.description}
  238. {:else}
  239. {model.id}
  240. {/if}
  241. </div>
  242. </div>
  243. </div>
  244. </a>
  245. </div>
  246. <div class="flex justify-between items-center -mb-0.5 px-0.5">
  247. <div class=" text-xs mt-0.5">
  248. <Tooltip
  249. content={model?.user?.email ?? $i18n.t('Deleted User')}
  250. className="flex shrink-0"
  251. placement="top-start"
  252. >
  253. <div class="shrink-0 text-gray-500">
  254. {$i18n.t('By {{name}}', {
  255. name: capitalizeFirstLetter(
  256. model?.user?.name ?? model?.user?.email ?? $i18n.t('Deleted User')
  257. )
  258. })}
  259. </div>
  260. </Tooltip>
  261. </div>
  262. <div class="flex flex-row gap-0.5 items-center">
  263. {#if shiftKey}
  264. <Tooltip content={$i18n.t('Delete')}>
  265. <button
  266. 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"
  267. type="button"
  268. on:click={() => {
  269. deleteModelHandler(model);
  270. }}
  271. >
  272. <GarbageBin />
  273. </button>
  274. </Tooltip>
  275. {:else}
  276. {#if $user?.role === 'admin' || model.user_id === $user?.id || model.access_control.write.group_ids.some( (wg) => group_ids.includes(wg) )}
  277. <a
  278. 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"
  279. type="button"
  280. href={`/workspace/models/edit?id=${encodeURIComponent(model.id)}`}
  281. >
  282. <svg
  283. xmlns="http://www.w3.org/2000/svg"
  284. fill="none"
  285. viewBox="0 0 24 24"
  286. stroke-width="1.5"
  287. stroke="currentColor"
  288. class="w-4 h-4"
  289. >
  290. <path
  291. stroke-linecap="round"
  292. stroke-linejoin="round"
  293. 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"
  294. />
  295. </svg>
  296. </a>
  297. {/if}
  298. <ModelMenu
  299. user={$user}
  300. {model}
  301. shareHandler={() => {
  302. shareModelHandler(model);
  303. }}
  304. cloneHandler={() => {
  305. cloneModelHandler(model);
  306. }}
  307. exportHandler={() => {
  308. exportModelHandler(model);
  309. }}
  310. hideHandler={() => {
  311. hideModelHandler(model);
  312. }}
  313. deleteHandler={() => {
  314. selectedModel = model;
  315. showModelDeleteConfirm = true;
  316. }}
  317. onClose={() => {}}
  318. >
  319. <button
  320. 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"
  321. type="button"
  322. >
  323. <EllipsisHorizontal className="size-5" />
  324. </button>
  325. </ModelMenu>
  326. <div class="ml-1">
  327. <Tooltip content={model.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
  328. <Switch
  329. bind:state={model.is_active}
  330. on:change={async (e) => {
  331. toggleModelById(localStorage.token, model.id);
  332. _models.set(
  333. await getModels(
  334. localStorage.token,
  335. $config?.features?.enable_direct_connections &&
  336. ($settings?.directConnections ?? null)
  337. )
  338. );
  339. }}
  340. />
  341. </Tooltip>
  342. </div>
  343. {/if}
  344. </div>
  345. </div>
  346. </div>
  347. {/each}
  348. </div>
  349. {#if $user?.role === 'admin'}
  350. <div class=" flex justify-end w-full mb-3">
  351. <div class="flex space-x-1">
  352. <input
  353. id="models-import-input"
  354. bind:this={modelsImportInputElement}
  355. bind:files={importFiles}
  356. type="file"
  357. accept=".json"
  358. hidden
  359. on:change={() => {
  360. console.log(importFiles);
  361. let reader = new FileReader();
  362. reader.onload = async (event) => {
  363. let savedModels = JSON.parse(event.target.result);
  364. console.log(savedModels);
  365. for (const model of savedModels) {
  366. if (model?.info ?? false) {
  367. if ($_models.find((m) => m.id === model.id)) {
  368. await updateModelById(localStorage.token, model.id, model.info).catch(
  369. (error) => {
  370. return null;
  371. }
  372. );
  373. } else {
  374. await createNewModel(localStorage.token, model.info).catch((error) => {
  375. return null;
  376. });
  377. }
  378. }
  379. }
  380. await _models.set(
  381. await getModels(
  382. localStorage.token,
  383. $config?.features?.enable_direct_connections &&
  384. ($settings?.directConnections ?? null)
  385. )
  386. );
  387. models = await getWorkspaceModels(localStorage.token);
  388. };
  389. reader.readAsText(importFiles[0]);
  390. }}
  391. />
  392. <button
  393. 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"
  394. on:click={() => {
  395. modelsImportInputElement.click();
  396. }}
  397. >
  398. <div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Models')}</div>
  399. <div class=" self-center">
  400. <svg
  401. xmlns="http://www.w3.org/2000/svg"
  402. viewBox="0 0 16 16"
  403. fill="currentColor"
  404. class="w-3.5 h-3.5"
  405. >
  406. <path
  407. fill-rule="evenodd"
  408. 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"
  409. clip-rule="evenodd"
  410. />
  411. </svg>
  412. </div>
  413. </button>
  414. <button
  415. 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"
  416. on:click={async () => {
  417. downloadModels($_models);
  418. }}
  419. >
  420. <div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Export Models')}</div>
  421. <div class=" self-center">
  422. <svg
  423. xmlns="http://www.w3.org/2000/svg"
  424. viewBox="0 0 16 16"
  425. fill="currentColor"
  426. class="w-3.5 h-3.5"
  427. >
  428. <path
  429. fill-rule="evenodd"
  430. 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"
  431. clip-rule="evenodd"
  432. />
  433. </svg>
  434. </div>
  435. </button>
  436. </div>
  437. </div>
  438. {/if}
  439. {#if $config?.features.enable_community_sharing}
  440. <div class=" my-16">
  441. <div class=" text-xl font-medium mb-1 line-clamp-1">
  442. {$i18n.t('Made by Open WebUI Community')}
  443. </div>
  444. <a
  445. 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"
  446. href="https://openwebui.com/#open-webui-community"
  447. target="_blank"
  448. >
  449. <div class=" self-center">
  450. <div class=" font-semibold line-clamp-1">{$i18n.t('Discover a model')}</div>
  451. <div class=" text-sm line-clamp-1">
  452. {$i18n.t('Discover, download, and explore model presets')}
  453. </div>
  454. </div>
  455. <div>
  456. <div>
  457. <ChevronRight />
  458. </div>
  459. </div>
  460. </a>
  461. </div>
  462. {/if}
  463. {:else}
  464. <div class="w-full h-full flex justify-center items-center">
  465. <Spinner />
  466. </div>
  467. {/if}