Prompts.svelte 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import fileSaver from 'file-saver';
  4. const { saveAs } = fileSaver;
  5. import { goto } from '$app/navigation';
  6. import { onMount, getContext } from 'svelte';
  7. import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores';
  8. import {
  9. createNewPrompt,
  10. deletePromptByCommand,
  11. getPrompts,
  12. getPromptList
  13. } from '$lib/apis/prompts';
  14. import PromptMenu from './Prompts/PromptMenu.svelte';
  15. import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
  16. import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
  17. import Search from '../icons/Search.svelte';
  18. import Plus from '../icons/Plus.svelte';
  19. import ChevronRight from '../icons/ChevronRight.svelte';
  20. import Spinner from '../common/Spinner.svelte';
  21. import Tooltip from '../common/Tooltip.svelte';
  22. import { capitalizeFirstLetter } from '$lib/utils';
  23. const i18n = getContext('i18n');
  24. let promptsImportInputElement: HTMLInputElement;
  25. let loaded = false;
  26. let importFiles = '';
  27. let query = '';
  28. let prompts = [];
  29. let showDeleteConfirm = false;
  30. let deletePrompt = null;
  31. let filteredItems = [];
  32. $: filteredItems = prompts.filter((p) => query === '' || p.command.includes(query));
  33. const shareHandler = async (prompt) => {
  34. toast.success($i18n.t('Redirecting you to Open WebUI Community'));
  35. const url = 'https://openwebui.com';
  36. const tab = await window.open(`${url}/prompts/create`, '_blank');
  37. window.addEventListener(
  38. 'message',
  39. (event) => {
  40. if (event.origin !== url) return;
  41. if (event.data === 'loaded') {
  42. tab.postMessage(JSON.stringify(prompt), '*');
  43. }
  44. },
  45. false
  46. );
  47. };
  48. const cloneHandler = async (prompt) => {
  49. sessionStorage.prompt = JSON.stringify(prompt);
  50. goto('/workspace/prompts/create');
  51. };
  52. const exportHandler = async (prompt) => {
  53. let blob = new Blob([JSON.stringify([prompt])], {
  54. type: 'application/json'
  55. });
  56. saveAs(blob, `prompt-export-${Date.now()}.json`);
  57. };
  58. const deleteHandler = async (prompt) => {
  59. const command = prompt.command;
  60. await deletePromptByCommand(localStorage.token, command);
  61. await init();
  62. };
  63. const init = async () => {
  64. prompts = await getPromptList(localStorage.token);
  65. await _prompts.set(await getPrompts(localStorage.token));
  66. };
  67. onMount(async () => {
  68. await init();
  69. loaded = true;
  70. });
  71. </script>
  72. <svelte:head>
  73. <title>
  74. {$i18n.t('Prompts')} | {$WEBUI_NAME}
  75. </title>
  76. </svelte:head>
  77. {#if loaded}
  78. <DeleteConfirmDialog
  79. bind:show={showDeleteConfirm}
  80. title={$i18n.t('Delete prompt?')}
  81. on:confirm={() => {
  82. deleteHandler(deletePrompt);
  83. }}
  84. >
  85. <div class=" text-sm text-gray-500">
  86. {$i18n.t('This will delete')} <span class=" font-semibold">{deletePrompt.command}</span>.
  87. </div>
  88. </DeleteConfirmDialog>
  89. <div class="flex flex-col gap-1 my-1.5">
  90. <div class="flex justify-between items-center">
  91. <div class="flex md:self-center text-xl font-medium px-0.5 items-center">
  92. {$i18n.t('Prompts')}
  93. <div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
  94. <span class="text-lg font-medium text-gray-500 dark:text-gray-300"
  95. >{filteredItems.length}</span
  96. >
  97. </div>
  98. </div>
  99. <div class=" flex w-full space-x-2">
  100. <div class="flex flex-1">
  101. <div class=" self-center ml-1 mr-3">
  102. <Search className="size-3.5" />
  103. </div>
  104. <input
  105. class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
  106. bind:value={query}
  107. placeholder={$i18n.t('Search Prompts')}
  108. />
  109. </div>
  110. <div>
  111. <a
  112. 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"
  113. href="/workspace/prompts/create"
  114. >
  115. <Plus className="size-3.5" />
  116. </a>
  117. </div>
  118. </div>
  119. </div>
  120. <div class="mb-5 gap-2 grid lg:grid-cols-2 xl:grid-cols-3">
  121. {#each filteredItems as prompt}
  122. <div
  123. class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl transition"
  124. >
  125. <div class=" flex flex-1 space-x-4 cursor-pointer w-full">
  126. <a href={`/workspace/prompts/edit?command=${encodeURIComponent(prompt.command)}`}>
  127. <div class=" flex-1 flex items-center gap-2 self-center">
  128. <div class=" font-semibold line-clamp-1 capitalize">{prompt.title}</div>
  129. <div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
  130. {prompt.command}
  131. </div>
  132. </div>
  133. <div class=" text-xs px-0.5">
  134. <Tooltip
  135. content={prompt?.user?.email ?? $i18n.t('Deleted User')}
  136. className="flex shrink-0"
  137. placement="top-start"
  138. >
  139. <div class="shrink-0 text-gray-500">
  140. {$i18n.t('By {{name}}', {
  141. name: capitalizeFirstLetter(
  142. prompt?.user?.name ?? prompt?.user?.email ?? $i18n.t('Deleted User')
  143. )
  144. })}
  145. </div>
  146. </Tooltip>
  147. </div>
  148. </a>
  149. </div>
  150. <div class="flex flex-row gap-0.5 self-center">
  151. <a
  152. 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"
  153. type="button"
  154. href={`/workspace/prompts/edit?command=${encodeURIComponent(prompt.command)}`}
  155. >
  156. <svg
  157. xmlns="http://www.w3.org/2000/svg"
  158. fill="none"
  159. viewBox="0 0 24 24"
  160. stroke-width="1.5"
  161. stroke="currentColor"
  162. class="w-4 h-4"
  163. >
  164. <path
  165. stroke-linecap="round"
  166. stroke-linejoin="round"
  167. d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
  168. />
  169. </svg>
  170. </a>
  171. <PromptMenu
  172. shareHandler={() => {
  173. shareHandler(prompt);
  174. }}
  175. cloneHandler={() => {
  176. cloneHandler(prompt);
  177. }}
  178. exportHandler={() => {
  179. exportHandler(prompt);
  180. }}
  181. deleteHandler={async () => {
  182. deletePrompt = prompt;
  183. showDeleteConfirm = true;
  184. }}
  185. onClose={() => {}}
  186. >
  187. <button
  188. 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"
  189. type="button"
  190. >
  191. <EllipsisHorizontal className="size-5" />
  192. </button>
  193. </PromptMenu>
  194. </div>
  195. </div>
  196. {/each}
  197. </div>
  198. {#if $user?.role === 'admin'}
  199. <div class=" flex justify-end w-full mb-3">
  200. <div class="flex space-x-2">
  201. <input
  202. id="prompts-import-input"
  203. bind:this={promptsImportInputElement}
  204. bind:files={importFiles}
  205. type="file"
  206. accept=".json"
  207. hidden
  208. on:change={() => {
  209. console.log(importFiles);
  210. const reader = new FileReader();
  211. reader.onload = async (event) => {
  212. const savedPrompts = JSON.parse(event.target.result);
  213. console.log(savedPrompts);
  214. for (const prompt of savedPrompts) {
  215. await createNewPrompt(localStorage.token, {
  216. command:
  217. prompt.command.charAt(0) === '/' ? prompt.command.slice(1) : prompt.command,
  218. title: prompt.title,
  219. content: prompt.content
  220. }).catch((error) => {
  221. toast.error(`${error}`);
  222. return null;
  223. });
  224. }
  225. prompts = await getPromptList(localStorage.token);
  226. await _prompts.set(await getPrompts(localStorage.token));
  227. importFiles = [];
  228. promptsImportInputElement.value = '';
  229. };
  230. reader.readAsText(importFiles[0]);
  231. }}
  232. />
  233. <button
  234. 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"
  235. on:click={() => {
  236. promptsImportInputElement.click();
  237. }}
  238. >
  239. <div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Prompts')}</div>
  240. <div class=" self-center">
  241. <svg
  242. xmlns="http://www.w3.org/2000/svg"
  243. viewBox="0 0 16 16"
  244. fill="currentColor"
  245. class="w-4 h-4"
  246. >
  247. <path
  248. fill-rule="evenodd"
  249. 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"
  250. clip-rule="evenodd"
  251. />
  252. </svg>
  253. </div>
  254. </button>
  255. <button
  256. 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"
  257. on:click={async () => {
  258. // promptsImportInputElement.click();
  259. let blob = new Blob([JSON.stringify(prompts)], {
  260. type: 'application/json'
  261. });
  262. saveAs(blob, `prompts-export-${Date.now()}.json`);
  263. }}
  264. >
  265. <div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Export Prompts')}</div>
  266. <div class=" self-center">
  267. <svg
  268. xmlns="http://www.w3.org/2000/svg"
  269. viewBox="0 0 16 16"
  270. fill="currentColor"
  271. class="w-4 h-4"
  272. >
  273. <path
  274. fill-rule="evenodd"
  275. 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"
  276. clip-rule="evenodd"
  277. />
  278. </svg>
  279. </div>
  280. </button>
  281. </div>
  282. </div>
  283. {/if}
  284. {#if $config?.features.enable_community_sharing}
  285. <div class=" my-16">
  286. <div class=" text-xl font-medium mb-1 line-clamp-1">
  287. {$i18n.t('Made by Open WebUI Community')}
  288. </div>
  289. <a
  290. 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"
  291. href="https://openwebui.com/#open-webui-community"
  292. target="_blank"
  293. >
  294. <div class=" self-center">
  295. <div class=" font-semibold line-clamp-1">{$i18n.t('Discover a prompt')}</div>
  296. <div class=" text-sm line-clamp-1">
  297. {$i18n.t('Discover, download, and explore custom prompts')}
  298. </div>
  299. </div>
  300. <div>
  301. <div>
  302. <ChevronRight />
  303. </div>
  304. </div>
  305. </a>
  306. </div>
  307. {/if}
  308. {:else}
  309. <div class="w-full h-full flex justify-center items-center">
  310. <Spinner />
  311. </div>
  312. {/if}