ModelEditor.svelte 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765
  1. <script lang="ts">
  2. import { onMount, getContext, tick } from 'svelte';
  3. import { models, tools, functions, knowledge as knowledgeCollections, user } from '$lib/stores';
  4. import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
  5. import Tags from '$lib/components/common/Tags.svelte';
  6. import Knowledge from '$lib/components/workspace/Models/Knowledge.svelte';
  7. import ToolsSelector from '$lib/components/workspace/Models/ToolsSelector.svelte';
  8. import FiltersSelector from '$lib/components/workspace/Models/FiltersSelector.svelte';
  9. import ActionsSelector from '$lib/components/workspace/Models/ActionsSelector.svelte';
  10. import Capabilities from '$lib/components/workspace/Models/Capabilities.svelte';
  11. import Textarea from '$lib/components/common/Textarea.svelte';
  12. import { getTools } from '$lib/apis/tools';
  13. import { getFunctions } from '$lib/apis/functions';
  14. import { getKnowledgeBases } from '$lib/apis/knowledge';
  15. import AccessControl from '../common/AccessControl.svelte';
  16. import { stringify } from 'postcss';
  17. const i18n = getContext('i18n');
  18. export let onSubmit: Function;
  19. export let onBack: null | Function = null;
  20. export let model = null;
  21. export let edit = false;
  22. export let preset = true;
  23. let loading = false;
  24. let success = false;
  25. let filesInputElement;
  26. let inputFiles;
  27. let showAdvanced = false;
  28. let showPreview = false;
  29. let loaded = false;
  30. // ///////////
  31. // model
  32. // ///////////
  33. let id = '';
  34. let name = '';
  35. $: if (!edit) {
  36. if (name) {
  37. id = name
  38. .replace(/\s+/g, '-')
  39. .replace(/[^a-zA-Z0-9-]/g, '')
  40. .toLowerCase();
  41. }
  42. }
  43. let info = {
  44. id: '',
  45. base_model_id: null,
  46. name: '',
  47. meta: {
  48. profile_image_url: '/static/favicon.png',
  49. description: '',
  50. suggestion_prompts: null,
  51. tags: []
  52. },
  53. params: {
  54. system: ''
  55. }
  56. };
  57. let params = {};
  58. let capabilities = {
  59. vision: true,
  60. usage: undefined
  61. };
  62. let knowledge = [];
  63. let toolIds = [];
  64. let filterIds = [];
  65. let actionIds = [];
  66. let accessControl = null;
  67. const addUsage = (base_model_id) => {
  68. const baseModel = $models.find((m) => m.id === base_model_id);
  69. if (baseModel) {
  70. if (baseModel.owned_by === 'openai') {
  71. capabilities.usage = baseModel?.meta?.capabilities?.usage ?? false;
  72. } else {
  73. delete capabilities.usage;
  74. }
  75. capabilities = capabilities;
  76. }
  77. };
  78. const submitHandler = async () => {
  79. loading = true;
  80. info.id = id;
  81. info.name = name;
  82. info.access_control = accessControl;
  83. info.meta.capabilities = capabilities;
  84. if (knowledge.length > 0) {
  85. info.meta.knowledge = knowledge;
  86. } else {
  87. if (info.meta.knowledge) {
  88. delete info.meta.knowledge;
  89. }
  90. }
  91. if (toolIds.length > 0) {
  92. info.meta.toolIds = toolIds;
  93. } else {
  94. if (info.meta.toolIds) {
  95. delete info.meta.toolIds;
  96. }
  97. }
  98. if (filterIds.length > 0) {
  99. info.meta.filterIds = filterIds;
  100. } else {
  101. if (info.meta.filterIds) {
  102. delete info.meta.filterIds;
  103. }
  104. }
  105. if (actionIds.length > 0) {
  106. info.meta.actionIds = actionIds;
  107. } else {
  108. if (info.meta.actionIds) {
  109. delete info.meta.actionIds;
  110. }
  111. }
  112. info.params.stop = params.stop ? params.stop.split(',').filter((s) => s.trim()) : null;
  113. Object.keys(info.params).forEach((key) => {
  114. if (info.params[key] === '' || info.params[key] === null) {
  115. delete info.params[key];
  116. }
  117. });
  118. await onSubmit(info);
  119. loading = false;
  120. success = false;
  121. };
  122. onMount(async () => {
  123. await tools.set(await getTools(localStorage.token));
  124. await functions.set(await getFunctions(localStorage.token));
  125. await knowledgeCollections.set(await getKnowledgeBases(localStorage.token));
  126. // Scroll to top 'workspace-container' element
  127. const workspaceContainer = document.getElementById('workspace-container');
  128. if (workspaceContainer) {
  129. workspaceContainer.scrollTop = 0;
  130. }
  131. if (model) {
  132. console.log(model);
  133. name = model.name;
  134. await tick();
  135. id = model.id;
  136. if (model.base_model_id) {
  137. const base_model = $models
  138. .filter((m) => !m?.preset && !(m?.arena ?? false))
  139. .find((m) => [model.base_model_id, `${model.base_model_id}:latest`].includes(m.id));
  140. console.log('base_model', base_model);
  141. if (base_model) {
  142. model.base_model_id = base_model.id;
  143. } else {
  144. model.base_model_id = null;
  145. }
  146. }
  147. params = { ...params, ...model?.params };
  148. params.stop = params?.stop
  149. ? (typeof params.stop === 'string' ? params.stop.split(',') : (params?.stop ?? [])).join(
  150. ','
  151. )
  152. : null;
  153. toolIds = model?.meta?.toolIds ?? [];
  154. filterIds = model?.meta?.filterIds ?? [];
  155. actionIds = model?.meta?.actionIds ?? [];
  156. knowledge = (model?.meta?.knowledge ?? []).map((item) => {
  157. if (item?.collection_name) {
  158. return {
  159. id: item.collection_name,
  160. name: item.name,
  161. legacy: true
  162. };
  163. } else if (item?.collection_names) {
  164. return {
  165. name: item.name,
  166. type: 'collection',
  167. collection_names: item.collection_names,
  168. legacy: true
  169. };
  170. } else {
  171. return item;
  172. }
  173. });
  174. capabilities = { ...capabilities, ...(model?.meta?.capabilities ?? {}) };
  175. if (model?.owned_by === 'openai') {
  176. capabilities.usage = false;
  177. }
  178. accessControl = model?.access_control ?? null;
  179. console.log(model?.access_control);
  180. console.log(accessControl);
  181. info = {
  182. ...info,
  183. ...JSON.parse(
  184. JSON.stringify(
  185. model
  186. ? model
  187. : {
  188. id: model.id,
  189. name: model.name
  190. }
  191. )
  192. )
  193. };
  194. console.log(model);
  195. }
  196. loaded = true;
  197. });
  198. </script>
  199. {#if loaded}
  200. {#if onBack}
  201. <button
  202. class="flex space-x-1"
  203. on:click={() => {
  204. onBack();
  205. }}
  206. >
  207. <div class=" self-center">
  208. <svg
  209. xmlns="http://www.w3.org/2000/svg"
  210. viewBox="0 0 20 20"
  211. fill="currentColor"
  212. class="h-4 w-4"
  213. >
  214. <path
  215. fill-rule="evenodd"
  216. d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z"
  217. clip-rule="evenodd"
  218. />
  219. </svg>
  220. </div>
  221. <div class=" self-center text-sm font-medium">{'Back'}</div>
  222. </button>
  223. {/if}
  224. <div class="w-full max-h-full flex justify-center">
  225. <input
  226. bind:this={filesInputElement}
  227. bind:files={inputFiles}
  228. type="file"
  229. hidden
  230. accept="image/*"
  231. on:change={() => {
  232. let reader = new FileReader();
  233. reader.onload = (event) => {
  234. let originalImageUrl = `${event.target.result}`;
  235. const img = new Image();
  236. img.src = originalImageUrl;
  237. img.onload = function () {
  238. const canvas = document.createElement('canvas');
  239. const ctx = canvas.getContext('2d');
  240. // Calculate the aspect ratio of the image
  241. const aspectRatio = img.width / img.height;
  242. // Calculate the new width and height to fit within 100x100
  243. let newWidth, newHeight;
  244. if (aspectRatio > 1) {
  245. newWidth = 250 * aspectRatio;
  246. newHeight = 250;
  247. } else {
  248. newWidth = 250;
  249. newHeight = 250 / aspectRatio;
  250. }
  251. // Set the canvas size
  252. canvas.width = 250;
  253. canvas.height = 250;
  254. // Calculate the position to center the image
  255. const offsetX = (250 - newWidth) / 2;
  256. const offsetY = (250 - newHeight) / 2;
  257. // Draw the image on the canvas
  258. ctx.drawImage(img, offsetX, offsetY, newWidth, newHeight);
  259. // Get the base64 representation of the compressed image
  260. const compressedSrc = canvas.toDataURL();
  261. // Display the compressed image
  262. info.meta.profile_image_url = compressedSrc;
  263. inputFiles = null;
  264. };
  265. };
  266. if (
  267. inputFiles &&
  268. inputFiles.length > 0 &&
  269. ['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/svg+xml'].includes(
  270. inputFiles[0]['type']
  271. )
  272. ) {
  273. reader.readAsDataURL(inputFiles[0]);
  274. } else {
  275. console.log(`Unsupported File Type '${inputFiles[0]['type']}'.`);
  276. inputFiles = null;
  277. }
  278. }}
  279. />
  280. {#if !edit || (edit && model)}
  281. <form
  282. class="flex flex-col md:flex-row w-full gap-3 md:gap-6"
  283. on:submit|preventDefault={() => {
  284. submitHandler();
  285. }}
  286. >
  287. <div class="self-center md:self-start flex justify-center my-2 flex-shrink-0">
  288. <div class="self-center">
  289. <button
  290. class="rounded-2xl flex flex-shrink-0 items-center bg-white shadow-2xl group relative"
  291. type="button"
  292. on:click={() => {
  293. filesInputElement.click();
  294. }}
  295. >
  296. {#if info.meta.profile_image_url}
  297. <img
  298. src={info.meta.profile_image_url}
  299. alt="model profile"
  300. class="rounded-lg size-72 md:size-60 object-cover shrink-0"
  301. />
  302. {:else}
  303. <img
  304. src="/static/favicon.png"
  305. alt="model profile"
  306. class=" rounded-lg size-72 md:size-60 object-cover shrink-0"
  307. />
  308. {/if}
  309. <div class="absolute bottom-0 right-0 z-10">
  310. <div class="m-1.5">
  311. <div
  312. class="shadow-xl p-1 rounded-full border-2 border-white bg-gray-800 text-white group-hover:bg-gray-600 transition dark:border-black dark:bg-white dark:group-hover:bg-gray-200 dark:text-black"
  313. >
  314. <svg
  315. xmlns="http://www.w3.org/2000/svg"
  316. viewBox="0 0 16 16"
  317. fill="currentColor"
  318. class="size-5"
  319. >
  320. <path
  321. fill-rule="evenodd"
  322. d="M2 4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4Zm10.5 5.707a.5.5 0 0 0-.146-.353l-1-1a.5.5 0 0 0-.708 0L9.354 9.646a.5.5 0 0 1-.708 0L6.354 7.354a.5.5 0 0 0-.708 0l-2 2a.5.5 0 0 0-.146.353V12a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V9.707ZM12 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
  323. clip-rule="evenodd"
  324. />
  325. </svg>
  326. </div>
  327. </div>
  328. </div>
  329. <div
  330. class="absolute top-0 bottom-0 left-0 right-0 bg-white dark:bg-black rounded-lg opacity-0 group-hover:opacity-20 transition"
  331. ></div>
  332. </button>
  333. </div>
  334. </div>
  335. <div class="w-full">
  336. <div class="mt-2 my-2 flex flex-col">
  337. <div class="flex-1">
  338. <div>
  339. <input
  340. class="text-3xl font-semibold w-full bg-transparent outline-none"
  341. placeholder={$i18n.t('Model Name')}
  342. bind:value={name}
  343. required
  344. />
  345. </div>
  346. </div>
  347. <div class="flex-1">
  348. <!-- <div class=" text-sm font-semibold">{$i18n.t('Model ID')}*</div> -->
  349. <div>
  350. <input
  351. class="text-xs w-full bg-transparent text-gray-500 outline-none"
  352. placeholder={$i18n.t('Model ID')}
  353. value={id}
  354. disabled={edit}
  355. required
  356. />
  357. </div>
  358. </div>
  359. </div>
  360. {#if preset}
  361. <div class="my-1">
  362. <div class=" text-sm font-semibold mb-1">{$i18n.t('Base Model (From)')}</div>
  363. <div>
  364. <select
  365. class="text-sm w-full bg-transparent outline-none"
  366. placeholder="Select a base model (e.g. llama3, gpt-4o)"
  367. bind:value={info.base_model_id}
  368. on:change={(e) => {
  369. addUsage(e.target.value);
  370. }}
  371. required
  372. >
  373. <option value={null} class=" text-gray-900"
  374. >{$i18n.t('Select a base model')}</option
  375. >
  376. {#each $models.filter((m) => (model ? m.id !== model.id : true) && !m?.preset && m?.owned_by !== 'arena') as model}
  377. <option value={model.id} class=" text-gray-900">{model.name}</option>
  378. {/each}
  379. </select>
  380. </div>
  381. </div>
  382. {/if}
  383. <div class="my-1">
  384. <div class="mb-1 flex w-full justify-between items-center">
  385. <div class=" self-center text-sm font-semibold">{$i18n.t('Description')}</div>
  386. <button
  387. class="p-1 text-xs flex rounded transition"
  388. type="button"
  389. on:click={() => {
  390. if (info.meta.description === null) {
  391. info.meta.description = '';
  392. } else {
  393. info.meta.description = null;
  394. }
  395. }}
  396. >
  397. {#if info.meta.description === null}
  398. <span class="ml-2 self-center">{$i18n.t('Default')}</span>
  399. {:else}
  400. <span class="ml-2 self-center">{$i18n.t('Custom')}</span>
  401. {/if}
  402. </button>
  403. </div>
  404. {#if info.meta.description !== null}
  405. <Textarea
  406. className=" text-sm w-full bg-transparent outline-none resize-none overflow-y-hidden "
  407. placeholder={$i18n.t('Add a short description about what this model does')}
  408. rows={3}
  409. bind:value={info.meta.description}
  410. />
  411. {/if}
  412. </div>
  413. <div class="my-1">
  414. <div class="">
  415. <Tags
  416. tags={info?.meta?.tags ?? []}
  417. on:delete={(e) => {
  418. const tagName = e.detail;
  419. info.meta.tags = info.meta.tags.filter((tag) => tag.name !== tagName);
  420. }}
  421. on:add={(e) => {
  422. const tagName = e.detail;
  423. if (!(info?.meta?.tags ?? null)) {
  424. info.meta.tags = [{ name: tagName }];
  425. } else {
  426. info.meta.tags = [...info.meta.tags, { name: tagName }];
  427. }
  428. }}
  429. />
  430. </div>
  431. </div>
  432. <div class="my-2">
  433. <div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
  434. <AccessControl bind:accessControl />
  435. </div>
  436. </div>
  437. <hr class=" border-gray-50 dark:border-gray-850 my-1.5" />
  438. <div class="my-2">
  439. <div class="flex w-full justify-between">
  440. <div class=" self-center text-sm font-semibold">{$i18n.t('Model Params')}</div>
  441. </div>
  442. <div class="mt-2">
  443. <div class="my-1">
  444. <div class=" text-xs font-semibold mb-2">{$i18n.t('System Prompt')}</div>
  445. <div>
  446. <Textarea
  447. className=" text-sm w-full bg-transparent outline-none resize-none overflow-y-hidden "
  448. placeholder={`Write your model system prompt content here\ne.g.) You are Mario from Super Mario Bros, acting as an assistant.`}
  449. rows={4}
  450. bind:value={info.params.system}
  451. />
  452. </div>
  453. </div>
  454. <div class="flex w-full justify-between">
  455. <div class=" self-center text-xs font-semibold">
  456. {$i18n.t('Advanced Params')}
  457. </div>
  458. <button
  459. class="p-1 px-3 text-xs flex rounded transition"
  460. type="button"
  461. on:click={() => {
  462. showAdvanced = !showAdvanced;
  463. }}
  464. >
  465. {#if showAdvanced}
  466. <span class="ml-2 self-center">{$i18n.t('Hide')}</span>
  467. {:else}
  468. <span class="ml-2 self-center">{$i18n.t('Show')}</span>
  469. {/if}
  470. </button>
  471. </div>
  472. {#if showAdvanced}
  473. <div class="my-2">
  474. <AdvancedParams
  475. admin={true}
  476. bind:params
  477. on:change={(e) => {
  478. info.params = { ...info.params, ...params };
  479. }}
  480. />
  481. </div>
  482. {/if}
  483. </div>
  484. </div>
  485. <hr class=" border-gray-50 dark:border-gray-850 my-1" />
  486. <div class="my-2">
  487. <div class="flex w-full justify-between items-center">
  488. <div class="flex w-full justify-between items-center">
  489. <div class=" self-center text-sm font-semibold">
  490. {$i18n.t('Prompt suggestions')}
  491. </div>
  492. <button
  493. class="p-1 text-xs flex rounded transition"
  494. type="button"
  495. on:click={() => {
  496. if ((info?.meta?.suggestion_prompts ?? null) === null) {
  497. info.meta.suggestion_prompts = [{ content: '' }];
  498. } else {
  499. info.meta.suggestion_prompts = null;
  500. }
  501. }}
  502. >
  503. {#if (info?.meta?.suggestion_prompts ?? null) === null}
  504. <span class="ml-2 self-center">{$i18n.t('Default')}</span>
  505. {:else}
  506. <span class="ml-2 self-center">{$i18n.t('Custom')}</span>
  507. {/if}
  508. </button>
  509. </div>
  510. {#if (info?.meta?.suggestion_prompts ?? null) !== null}
  511. <button
  512. class="p-1 px-2 text-xs flex rounded transition"
  513. type="button"
  514. on:click={() => {
  515. if (
  516. info.meta.suggestion_prompts.length === 0 ||
  517. info.meta.suggestion_prompts.at(-1).content !== ''
  518. ) {
  519. info.meta.suggestion_prompts = [
  520. ...info.meta.suggestion_prompts,
  521. { content: '' }
  522. ];
  523. }
  524. }}
  525. >
  526. <svg
  527. xmlns="http://www.w3.org/2000/svg"
  528. viewBox="0 0 20 20"
  529. fill="currentColor"
  530. class="w-4 h-4"
  531. >
  532. <path
  533. d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
  534. />
  535. </svg>
  536. </button>
  537. {/if}
  538. </div>
  539. {#if info?.meta?.suggestion_prompts}
  540. <div class="flex flex-col space-y-1 mt-1 mb-3">
  541. {#if info.meta.suggestion_prompts.length > 0}
  542. {#each info.meta.suggestion_prompts as prompt, promptIdx}
  543. <div class=" flex rounded-lg">
  544. <input
  545. class=" text-sm w-full bg-transparent outline-none border-r border-gray-50 dark:border-gray-850"
  546. placeholder={$i18n.t('Write a prompt suggestion (e.g. Who are you?)')}
  547. bind:value={prompt.content}
  548. />
  549. <button
  550. class="px-2"
  551. type="button"
  552. on:click={() => {
  553. info.meta.suggestion_prompts.splice(promptIdx, 1);
  554. info.meta.suggestion_prompts = info.meta.suggestion_prompts;
  555. }}
  556. >
  557. <svg
  558. xmlns="http://www.w3.org/2000/svg"
  559. viewBox="0 0 20 20"
  560. fill="currentColor"
  561. class="w-4 h-4"
  562. >
  563. <path
  564. d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
  565. />
  566. </svg>
  567. </button>
  568. </div>
  569. {/each}
  570. {:else}
  571. <div class="text-xs text-center">No suggestion prompts</div>
  572. {/if}
  573. </div>
  574. {/if}
  575. </div>
  576. <hr class=" border-gray-50 dark:border-gray-850 my-1.5" />
  577. <div class="my-2">
  578. <Knowledge bind:selectedKnowledge={knowledge} collections={$knowledgeCollections} />
  579. </div>
  580. <div class="my-2">
  581. <ToolsSelector bind:selectedToolIds={toolIds} tools={$tools} />
  582. </div>
  583. <div class="my-2">
  584. <FiltersSelector
  585. bind:selectedFilterIds={filterIds}
  586. filters={$functions.filter((func) => func.type === 'filter')}
  587. />
  588. </div>
  589. <div class="my-2">
  590. <ActionsSelector
  591. bind:selectedActionIds={actionIds}
  592. actions={$functions.filter((func) => func.type === 'action')}
  593. />
  594. </div>
  595. <div class="my-2">
  596. <Capabilities bind:capabilities />
  597. </div>
  598. <div class="my-2 text-gray-300 dark:text-gray-700">
  599. <div class="flex w-full justify-between mb-2">
  600. <div class=" self-center text-sm font-semibold">{$i18n.t('JSON Preview')}</div>
  601. <button
  602. class="p-1 px-3 text-xs flex rounded transition"
  603. type="button"
  604. on:click={() => {
  605. showPreview = !showPreview;
  606. }}
  607. >
  608. {#if showPreview}
  609. <span class="ml-2 self-center">{$i18n.t('Hide')}</span>
  610. {:else}
  611. <span class="ml-2 self-center">{$i18n.t('Show')}</span>
  612. {/if}
  613. </button>
  614. </div>
  615. {#if showPreview}
  616. <div>
  617. <textarea
  618. class="text-sm w-full bg-transparent outline-none resize-none"
  619. rows="10"
  620. value={JSON.stringify(info, null, 2)}
  621. disabled
  622. readonly
  623. />
  624. </div>
  625. {/if}
  626. </div>
  627. <div class="my-2 flex justify-end pb-20">
  628. <button
  629. class=" text-sm px-3 py-2 transition rounded-lg {loading
  630. ? ' cursor-not-allowed bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'
  631. : 'bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'} flex w-full justify-center"
  632. type="submit"
  633. disabled={loading}
  634. >
  635. <div class=" self-center font-medium">
  636. {#if edit}
  637. {$i18n.t('Save & Update')}
  638. {:else}
  639. {$i18n.t('Save & Create')}
  640. {/if}
  641. </div>
  642. {#if loading}
  643. <div class="ml-1.5 self-center">
  644. <svg
  645. class=" w-4 h-4"
  646. viewBox="0 0 24 24"
  647. fill="currentColor"
  648. xmlns="http://www.w3.org/2000/svg"
  649. ><style>
  650. .spinner_ajPY {
  651. transform-origin: center;
  652. animation: spinner_AtaB 0.75s infinite linear;
  653. }
  654. @keyframes spinner_AtaB {
  655. 100% {
  656. transform: rotate(360deg);
  657. }
  658. }
  659. </style><path
  660. d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
  661. opacity=".25"
  662. /><path
  663. d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
  664. class="spinner_ajPY"
  665. /></svg
  666. >
  667. </div>
  668. {/if}
  669. </button>
  670. </div>
  671. </div>
  672. </form>
  673. {/if}
  674. </div>
  675. {/if}