Models.svelte 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. <script lang="ts">
  2. import queue from 'async/queue';
  3. import toast from 'svelte-french-toast';
  4. import { createModel, deleteModel, pullModel } from '$lib/apis/ollama';
  5. import { WEBUI_API_BASE_URL } from '$lib/constants';
  6. import { models, user } from '$lib/stores';
  7. import { splitStream } from '$lib/utils';
  8. export let getModels: Function;
  9. // Models
  10. const MAX_PARALLEL_DOWNLOADS = 3;
  11. const modelDownloadQueue = queue(
  12. (task: { modelName: string }, cb) =>
  13. pullModelHandlerProcessor({ modelName: task.modelName, callback: cb }),
  14. MAX_PARALLEL_DOWNLOADS
  15. );
  16. let modelDownloadStatus: Record<string, any> = {};
  17. let modelTransferring = false;
  18. let modelTag = '';
  19. let digest = '';
  20. let pullProgress = null;
  21. let modelUploadMode = 'file';
  22. let modelInputFile = '';
  23. let modelFileUrl = '';
  24. let modelFileContent = `TEMPLATE """{{ .System }}\nUSER: {{ .Prompt }}\nASSSISTANT: """\nPARAMETER num_ctx 4096\nPARAMETER stop "</s>"\nPARAMETER stop "USER:"\nPARAMETER stop "ASSSISTANT:"`;
  25. let modelFileDigest = '';
  26. let uploadProgress = null;
  27. let deleteModelTag = '';
  28. const pullModelHandler = async () => {
  29. const sanitizedModelTag = modelTag.trim();
  30. if (modelDownloadStatus[sanitizedModelTag]) {
  31. toast.error(`Model '${sanitizedModelTag}' is already in queue for downloading.`);
  32. return;
  33. }
  34. if (Object.keys(modelDownloadStatus).length === 3) {
  35. toast.error('Maximum of 3 models can be downloaded simultaneously. Please try again later.');
  36. return;
  37. }
  38. modelTransferring = true;
  39. modelDownloadQueue.push(
  40. { modelName: sanitizedModelTag },
  41. async (data: { modelName: string; success: boolean; error?: Error }) => {
  42. const { modelName } = data;
  43. // Remove the downloaded model
  44. delete modelDownloadStatus[modelName];
  45. console.log(data);
  46. if (!data.success) {
  47. toast.error(data.error);
  48. } else {
  49. toast.success(`Model '${modelName}' has been successfully downloaded.`);
  50. const notification = new Notification(`Ollama`, {
  51. body: `Model '${modelName}' has been successfully downloaded.`,
  52. icon: '/favicon.png'
  53. });
  54. models.set(await getModels());
  55. }
  56. }
  57. );
  58. modelTag = '';
  59. modelTransferring = false;
  60. };
  61. const uploadModelHandler = async () => {
  62. modelTransferring = true;
  63. uploadProgress = 0;
  64. let uploaded = false;
  65. let fileResponse = null;
  66. let name = '';
  67. if (modelUploadMode === 'file') {
  68. const file = modelInputFile[0];
  69. const formData = new FormData();
  70. formData.append('file', file);
  71. fileResponse = await fetch(`${WEBUI_API_BASE_URL}/utils/upload`, {
  72. method: 'POST',
  73. headers: {
  74. ...($user && { Authorization: `Bearer ${localStorage.token}` })
  75. },
  76. body: formData
  77. }).catch((error) => {
  78. console.log(error);
  79. return null;
  80. });
  81. } else {
  82. fileResponse = await fetch(`${WEBUI_API_BASE_URL}/utils/download?url=${modelFileUrl}`, {
  83. method: 'GET',
  84. headers: {
  85. ...($user && { Authorization: `Bearer ${localStorage.token}` })
  86. }
  87. }).catch((error) => {
  88. console.log(error);
  89. return null;
  90. });
  91. }
  92. if (fileResponse && fileResponse.ok) {
  93. const reader = fileResponse.body
  94. .pipeThrough(new TextDecoderStream())
  95. .pipeThrough(splitStream('\n'))
  96. .getReader();
  97. while (true) {
  98. const { value, done } = await reader.read();
  99. if (done) break;
  100. try {
  101. let lines = value.split('\n');
  102. for (const line of lines) {
  103. if (line !== '') {
  104. let data = JSON.parse(line.replace(/^data: /, ''));
  105. if (data.progress) {
  106. uploadProgress = data.progress;
  107. }
  108. if (data.error) {
  109. throw data.error;
  110. }
  111. if (data.done) {
  112. modelFileDigest = data.blob;
  113. name = data.name;
  114. uploaded = true;
  115. }
  116. }
  117. }
  118. } catch (error) {
  119. console.log(error);
  120. }
  121. }
  122. }
  123. if (uploaded) {
  124. const res = await createModel(
  125. localStorage.token,
  126. `${name}:latest`,
  127. `FROM @${modelFileDigest}\n${modelFileContent}`
  128. );
  129. if (res && res.ok) {
  130. const reader = res.body
  131. .pipeThrough(new TextDecoderStream())
  132. .pipeThrough(splitStream('\n'))
  133. .getReader();
  134. while (true) {
  135. const { value, done } = await reader.read();
  136. if (done) break;
  137. try {
  138. let lines = value.split('\n');
  139. for (const line of lines) {
  140. if (line !== '') {
  141. console.log(line);
  142. let data = JSON.parse(line);
  143. console.log(data);
  144. if (data.error) {
  145. throw data.error;
  146. }
  147. if (data.detail) {
  148. throw data.detail;
  149. }
  150. if (data.status) {
  151. if (
  152. !data.digest &&
  153. !data.status.includes('writing') &&
  154. !data.status.includes('sha256')
  155. ) {
  156. toast.success(data.status);
  157. } else {
  158. if (data.digest) {
  159. digest = data.digest;
  160. if (data.completed) {
  161. pullProgress = Math.round((data.completed / data.total) * 1000) / 10;
  162. } else {
  163. pullProgress = 100;
  164. }
  165. }
  166. }
  167. }
  168. }
  169. }
  170. } catch (error) {
  171. console.log(error);
  172. toast.error(error);
  173. }
  174. }
  175. }
  176. }
  177. modelFileUrl = '';
  178. modelInputFile = '';
  179. modelTransferring = false;
  180. uploadProgress = null;
  181. models.set(await getModels());
  182. };
  183. const deleteModelHandler = async () => {
  184. const res = await deleteModel(localStorage.token, deleteModelTag).catch((error) => {
  185. toast.error(error);
  186. });
  187. if (res) {
  188. toast.success(`Deleted ${deleteModelTag}`);
  189. }
  190. deleteModelTag = '';
  191. models.set(await getModels());
  192. };
  193. const pullModelHandlerProcessor = async (opts: { modelName: string; callback: Function }) => {
  194. const res = await pullModel(localStorage.token, opts.modelName).catch((error) => {
  195. opts.callback({ success: false, error, modelName: opts.modelName });
  196. return null;
  197. });
  198. if (res) {
  199. const reader = res.body
  200. .pipeThrough(new TextDecoderStream())
  201. .pipeThrough(splitStream('\n'))
  202. .getReader();
  203. while (true) {
  204. try {
  205. const { value, done } = await reader.read();
  206. if (done) break;
  207. let lines = value.split('\n');
  208. for (const line of lines) {
  209. if (line !== '') {
  210. let data = JSON.parse(line);
  211. if (data.error) {
  212. throw data.error;
  213. }
  214. if (data.detail) {
  215. throw data.detail;
  216. }
  217. if (data.status) {
  218. if (data.digest) {
  219. let downloadProgress = 0;
  220. if (data.completed) {
  221. downloadProgress = Math.round((data.completed / data.total) * 1000) / 10;
  222. } else {
  223. downloadProgress = 100;
  224. }
  225. modelDownloadStatus[opts.modelName] = {
  226. pullProgress: downloadProgress,
  227. digest: data.digest
  228. };
  229. } else {
  230. toast.success(data.status);
  231. }
  232. }
  233. }
  234. }
  235. } catch (error) {
  236. console.log(error);
  237. if (typeof error !== 'string') {
  238. error = error.message;
  239. }
  240. opts.callback({ success: false, error, modelName: opts.modelName });
  241. }
  242. }
  243. opts.callback({ success: true, modelName: opts.modelName });
  244. }
  245. };
  246. </script>
  247. <div class="flex flex-col h-full justify-between text-sm">
  248. <div class=" space-y-3 pr-1.5 overflow-y-scroll h-80">
  249. <div>
  250. <div class=" mb-2.5 text-sm font-medium">Pull a model from Ollama.com</div>
  251. <div class="flex w-full">
  252. <div class="flex-1 mr-2">
  253. <input
  254. class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
  255. placeholder="Enter model tag (e.g. mistral:7b)"
  256. bind:value={modelTag}
  257. />
  258. </div>
  259. <button
  260. class="px-3 text-gray-100 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded transition"
  261. on:click={() => {
  262. pullModelHandler();
  263. }}
  264. disabled={modelTransferring}
  265. >
  266. {#if modelTransferring}
  267. <div class="self-center">
  268. <svg
  269. class=" w-4 h-4"
  270. viewBox="0 0 24 24"
  271. fill="currentColor"
  272. xmlns="http://www.w3.org/2000/svg"
  273. ><style>
  274. .spinner_ajPY {
  275. transform-origin: center;
  276. animation: spinner_AtaB 0.75s infinite linear;
  277. }
  278. @keyframes spinner_AtaB {
  279. 100% {
  280. transform: rotate(360deg);
  281. }
  282. }
  283. </style><path
  284. 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"
  285. opacity=".25"
  286. /><path
  287. 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"
  288. class="spinner_ajPY"
  289. /></svg
  290. >
  291. </div>
  292. {:else}
  293. <svg
  294. xmlns="http://www.w3.org/2000/svg"
  295. viewBox="0 0 16 16"
  296. fill="currentColor"
  297. class="w-4 h-4"
  298. >
  299. <path
  300. d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
  301. />
  302. <path
  303. d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
  304. />
  305. </svg>
  306. {/if}
  307. </button>
  308. </div>
  309. <div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
  310. To access the available model names for downloading, <a
  311. class=" text-gray-500 dark:text-gray-300 font-medium"
  312. href="https://ollama.com/library"
  313. target="_blank">click here.</a
  314. >
  315. </div>
  316. {#if Object.keys(modelDownloadStatus).length > 0}
  317. {#each Object.keys(modelDownloadStatus) as model}
  318. <div class="flex flex-col">
  319. <div class="font-medium mb-1">{model}</div>
  320. <div class="">
  321. <div
  322. class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
  323. style="width: {Math.max(15, modelDownloadStatus[model].pullProgress ?? 0)}%"
  324. >
  325. {modelDownloadStatus[model].pullProgress ?? 0}%
  326. </div>
  327. <div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
  328. {modelDownloadStatus[model].digest}
  329. </div>
  330. </div>
  331. </div>
  332. {/each}
  333. {/if}
  334. </div>
  335. <hr class=" dark:border-gray-700" />
  336. <div>
  337. <div class=" mb-2.5 text-sm font-medium">Delete a model</div>
  338. <div class="flex w-full">
  339. <div class="flex-1 mr-2">
  340. <select
  341. class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
  342. bind:value={deleteModelTag}
  343. placeholder="Select a model"
  344. >
  345. {#if !deleteModelTag}
  346. <option value="" disabled selected>Select a model</option>
  347. {/if}
  348. {#each $models.filter((m) => m.size != null) as model}
  349. <option value={model.name} class="bg-gray-100 dark:bg-gray-700"
  350. >{model.name + ' (' + (model.size / 1024 ** 3).toFixed(1) + ' GB)'}</option
  351. >
  352. {/each}
  353. </select>
  354. </div>
  355. <button
  356. class="px-3 bg-red-700 hover:bg-red-800 text-gray-100 rounded transition"
  357. on:click={() => {
  358. deleteModelHandler();
  359. }}
  360. >
  361. <svg
  362. xmlns="http://www.w3.org/2000/svg"
  363. viewBox="0 0 16 16"
  364. fill="currentColor"
  365. class="w-4 h-4"
  366. >
  367. <path
  368. fill-rule="evenodd"
  369. d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
  370. clip-rule="evenodd"
  371. />
  372. </svg>
  373. </button>
  374. </div>
  375. </div>
  376. <hr class=" dark:border-gray-700" />
  377. <form
  378. on:submit|preventDefault={() => {
  379. uploadModelHandler();
  380. }}
  381. >
  382. <div class=" mb-2 flex w-full justify-between">
  383. <div class=" text-sm font-medium">
  384. Upload a GGUF model <a
  385. class=" text-xs font-medium text-gray-500 underline"
  386. href="https://github.com/jmorganca/ollama/blob/main/README.md#import-from-gguf"
  387. target="_blank">(Experimental)</a
  388. >
  389. </div>
  390. <button
  391. class="p-1 px-3 text-xs flex rounded transition"
  392. on:click={() => {
  393. if (modelUploadMode === 'file') {
  394. modelUploadMode = 'url';
  395. } else {
  396. modelUploadMode = 'file';
  397. }
  398. }}
  399. type="button"
  400. >
  401. {#if modelUploadMode === 'file'}
  402. <span class="ml-2 self-center">File Mode</span>
  403. {:else}
  404. <span class="ml-2 self-center">URL Mode</span>
  405. {/if}
  406. </button>
  407. </div>
  408. <div class="flex w-full mb-1.5">
  409. <div class="flex flex-col w-full">
  410. {#if modelUploadMode === 'file'}
  411. <div class="flex-1 {modelInputFile && modelInputFile.length > 0 ? 'mr-2' : ''}">
  412. <input
  413. id="model-upload-input"
  414. type="file"
  415. bind:files={modelInputFile}
  416. on:change={() => {
  417. console.log(modelInputFile);
  418. }}
  419. accept=".gguf"
  420. required
  421. hidden
  422. />
  423. <button
  424. type="button"
  425. class="w-full rounded text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-800"
  426. on:click={() => {
  427. document.getElementById('model-upload-input').click();
  428. }}
  429. >
  430. {#if modelInputFile && modelInputFile.length > 0}
  431. {modelInputFile[0].name}
  432. {:else}
  433. Click here to select
  434. {/if}
  435. </button>
  436. </div>
  437. {:else}
  438. <div class="flex-1 {modelFileUrl !== '' ? 'mr-2' : ''}">
  439. <input
  440. class="w-full rounded text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-800 outline-none {modelFileUrl !==
  441. ''
  442. ? 'mr-2'
  443. : ''}"
  444. type="url"
  445. required
  446. bind:value={modelFileUrl}
  447. placeholder="Type HuggingFace Resolve (Download) URL"
  448. />
  449. </div>
  450. {/if}
  451. </div>
  452. {#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
  453. <button
  454. class="px-3 text-gray-100 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded transition"
  455. type="submit"
  456. disabled={modelTransferring}
  457. >
  458. {#if modelTransferring}
  459. <div class="self-center">
  460. <svg
  461. class=" w-4 h-4"
  462. viewBox="0 0 24 24"
  463. fill="currentColor"
  464. xmlns="http://www.w3.org/2000/svg"
  465. ><style>
  466. .spinner_ajPY {
  467. transform-origin: center;
  468. animation: spinner_AtaB 0.75s infinite linear;
  469. }
  470. @keyframes spinner_AtaB {
  471. 100% {
  472. transform: rotate(360deg);
  473. }
  474. }
  475. </style><path
  476. 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"
  477. opacity=".25"
  478. /><path
  479. 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"
  480. class="spinner_ajPY"
  481. /></svg
  482. >
  483. </div>
  484. {:else}
  485. <svg
  486. xmlns="http://www.w3.org/2000/svg"
  487. viewBox="0 0 16 16"
  488. fill="currentColor"
  489. class="w-4 h-4"
  490. >
  491. <path
  492. d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
  493. />
  494. <path
  495. d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
  496. />
  497. </svg>
  498. {/if}
  499. </button>
  500. {/if}
  501. </div>
  502. {#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
  503. <div>
  504. <div>
  505. <div class=" my-2.5 text-sm font-medium">Modelfile Content</div>
  506. <textarea
  507. bind:value={modelFileContent}
  508. class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none resize-none"
  509. rows="6"
  510. />
  511. </div>
  512. </div>
  513. {/if}
  514. <div class=" mt-1 text-xs text-gray-400 dark:text-gray-500">
  515. To access the GGUF models available for downloading, <a
  516. class=" text-gray-500 dark:text-gray-300 font-medium"
  517. href="https://huggingface.co/models?search=gguf"
  518. target="_blank">click here.</a
  519. >
  520. </div>
  521. {#if uploadProgress !== null}
  522. <div class="mt-2">
  523. <div class=" mb-2 text-xs">Upload Progress</div>
  524. <div class="w-full rounded-full dark:bg-gray-800">
  525. <div
  526. class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
  527. style="width: {Math.max(15, uploadProgress ?? 0)}%"
  528. >
  529. {uploadProgress ?? 0}%
  530. </div>
  531. </div>
  532. <div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
  533. {modelFileDigest}
  534. </div>
  535. </div>
  536. {/if}
  537. </form>
  538. </div>
  539. </div>