Chat.svelte 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import { goto } from '$app/navigation';
  4. import { onMount, tick, getContext } from 'svelte';
  5. import {
  6. OLLAMA_API_BASE_URL,
  7. OPENAI_API_BASE_URL,
  8. WEBUI_API_BASE_URL,
  9. WEBUI_BASE_URL
  10. } from '$lib/constants';
  11. import { WEBUI_NAME, config, user, models, settings } from '$lib/stores';
  12. import { chatCompletion, generateOpenAIChatCompletion } from '$lib/apis/openai';
  13. import { splitStream } from '$lib/utils';
  14. import Collapsible from '../common/Collapsible.svelte';
  15. import Messages from '$lib/components/playground/Chat/Messages.svelte';
  16. import ChevronUp from '../icons/ChevronUp.svelte';
  17. import ChevronDown from '../icons/ChevronDown.svelte';
  18. import Pencil from '../icons/Pencil.svelte';
  19. import Cog6 from '../icons/Cog6.svelte';
  20. import Sidebar from '../common/Sidebar.svelte';
  21. import ArrowRight from '../icons/ArrowRight.svelte';
  22. const i18n = getContext('i18n');
  23. let loaded = false;
  24. let selectedModelId = '';
  25. let loading = false;
  26. let stopResponseFlag = false;
  27. let systemTextareaElement: HTMLTextAreaElement;
  28. let messagesContainerElement: HTMLDivElement;
  29. let showSystem = false;
  30. let showSettings = false;
  31. let system = '';
  32. let role = 'user';
  33. let message = '';
  34. let messages = [];
  35. const scrollToBottom = () => {
  36. const element = messagesContainerElement;
  37. if (element) {
  38. element.scrollTop = element?.scrollHeight;
  39. }
  40. };
  41. const stopResponse = () => {
  42. stopResponseFlag = true;
  43. console.log('stopResponse');
  44. };
  45. const resizeSystemTextarea = async () => {
  46. await tick();
  47. if (systemTextareaElement) {
  48. systemTextareaElement.style.height = '';
  49. systemTextareaElement.style.height = Math.min(systemTextareaElement.scrollHeight, 555) + 'px';
  50. }
  51. };
  52. $: if (showSystem) {
  53. resizeSystemTextarea();
  54. }
  55. const chatCompletionHandler = async () => {
  56. if (selectedModelId === '') {
  57. toast.error($i18n.t('Please select a model.'));
  58. return;
  59. }
  60. const model = $models.find((model) => model.id === selectedModelId);
  61. if (!model) {
  62. selectedModelId = '';
  63. return;
  64. }
  65. const [res, controller] = await chatCompletion(
  66. localStorage.token,
  67. {
  68. model: model.id,
  69. stream: true,
  70. messages: [
  71. system
  72. ? {
  73. role: 'system',
  74. content: system
  75. }
  76. : undefined,
  77. ...messages
  78. ].filter((message) => message)
  79. },
  80. `${WEBUI_BASE_URL}/api`
  81. );
  82. let responseMessage;
  83. if (messages.at(-1)?.role === 'assistant') {
  84. responseMessage = messages.at(-1);
  85. } else {
  86. responseMessage = {
  87. role: 'assistant',
  88. content: ''
  89. };
  90. messages.push(responseMessage);
  91. messages = messages;
  92. }
  93. await tick();
  94. const textareaElement = document.getElementById(`assistant-${messages.length - 1}-textarea`);
  95. if (res && res.ok) {
  96. const reader = res.body
  97. .pipeThrough(new TextDecoderStream())
  98. .pipeThrough(splitStream('\n'))
  99. .getReader();
  100. while (true) {
  101. const { value, done } = await reader.read();
  102. if (done || stopResponseFlag) {
  103. if (stopResponseFlag) {
  104. controller.abort('User: Stop Response');
  105. }
  106. break;
  107. }
  108. try {
  109. let lines = value.split('\n');
  110. for (const line of lines) {
  111. if (line !== '') {
  112. console.log(line);
  113. if (line === 'data: [DONE]') {
  114. // responseMessage.done = true;
  115. messages = messages;
  116. } else {
  117. let data = JSON.parse(line.replace(/^data: /, ''));
  118. console.log(data);
  119. if (responseMessage.content == '' && data.choices[0].delta.content == '\n') {
  120. continue;
  121. } else {
  122. textareaElement.style.height = textareaElement.scrollHeight + 'px';
  123. responseMessage.content += data.choices[0].delta.content ?? '';
  124. messages = messages;
  125. textareaElement.style.height = textareaElement.scrollHeight + 'px';
  126. await tick();
  127. }
  128. }
  129. }
  130. }
  131. } catch (error) {
  132. console.log(error);
  133. }
  134. scrollToBottom();
  135. }
  136. }
  137. };
  138. const addHandler = async () => {
  139. if (message) {
  140. messages.push({
  141. role: role,
  142. content: message
  143. });
  144. messages = messages;
  145. message = '';
  146. await tick();
  147. scrollToBottom();
  148. }
  149. };
  150. const submitHandler = async () => {
  151. if (selectedModelId) {
  152. await addHandler();
  153. loading = true;
  154. await chatCompletionHandler();
  155. loading = false;
  156. stopResponseFlag = false;
  157. }
  158. };
  159. onMount(async () => {
  160. if ($user?.role !== 'admin') {
  161. await goto('/');
  162. }
  163. if ($settings?.models) {
  164. selectedModelId = $settings?.models[0];
  165. } else if ($config?.default_models) {
  166. selectedModelId = $config?.default_models.split(',')[0];
  167. } else {
  168. selectedModelId = '';
  169. }
  170. loaded = true;
  171. });
  172. </script>
  173. <div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
  174. <div class="mx-auto w-full md:px-0 h-full relative">
  175. <Sidebar bind:show={showSettings} className=" bg-white dark:bg-gray-900" width="300px">
  176. <div class="flex flex-col px-5 py-3 text-sm">
  177. <div class="flex justify-between items-center mb-2">
  178. <div class=" font-medium text-base">Settings</div>
  179. <div class=" translate-x-1.5">
  180. <button
  181. class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
  182. on:click={() => {
  183. showSettings = !showSettings;
  184. }}
  185. >
  186. <ArrowRight className="size-3" strokeWidth="2.5" />
  187. </button>
  188. </div>
  189. </div>
  190. <div class="mt-1">
  191. <div>
  192. <div class=" text-xs font-medium mb-1">Model</div>
  193. <div class="w-full">
  194. <select
  195. class="w-full bg-transparent border border-gray-50 dark:border-gray-850 rounded-lg py-1 px-2 -mx-0.5 text-sm outline-none"
  196. bind:value={selectedModelId}
  197. >
  198. {#each $models as model}
  199. <option value={model.id} class="bg-gray-50 dark:bg-gray-700">{model.name}</option>
  200. {/each}
  201. </select>
  202. </div>
  203. </div>
  204. </div>
  205. </div>
  206. </Sidebar>
  207. <div class=" flex flex-col h-full px-3.5">
  208. <div class="flex w-full items-start gap-1.5">
  209. <Collapsible
  210. className="w-full flex-1"
  211. bind:open={showSystem}
  212. buttonClassName="w-full rounded-lg text-sm border border-gray-50 dark:border-gray-850 w-full py-1 px-1.5"
  213. grow={true}
  214. >
  215. <div class="flex gap-2 justify-between items-center">
  216. <div class=" flex-shrink-0 font-medium ml-1.5">
  217. {$i18n.t('System Instructions')}
  218. </div>
  219. {#if !showSystem}
  220. <div class=" flex-1 text-gray-500 line-clamp-1">
  221. {system}
  222. </div>
  223. {/if}
  224. <div class="flex-shrink-0">
  225. <button class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg">
  226. {#if showSystem}
  227. <ChevronUp className="size-3.5" />
  228. {:else}
  229. <Pencil className="size-3.5" />
  230. {/if}
  231. </button>
  232. </div>
  233. </div>
  234. <div slot="content">
  235. <div class="pt-1 px-1.5">
  236. <textarea
  237. bind:this={systemTextareaElement}
  238. class="w-full h-full bg-transparent resize-none outline-none text-sm"
  239. bind:value={system}
  240. placeholder={$i18n.t("You're a helpful assistant.")}
  241. on:input={() => {
  242. resizeSystemTextarea();
  243. }}
  244. rows="4"
  245. />
  246. </div>
  247. </div>
  248. </Collapsible>
  249. <div class="translate-y-1">
  250. <button
  251. class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
  252. on:click={() => {
  253. showSettings = !showSettings;
  254. }}
  255. >
  256. <Cog6 />
  257. </button>
  258. </div>
  259. </div>
  260. <div
  261. class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0"
  262. id="messages-container"
  263. bind:this={messagesContainerElement}
  264. >
  265. <div class=" h-full w-full flex flex-col">
  266. <div class="flex-1 p-1">
  267. <Messages bind:messages />
  268. </div>
  269. </div>
  270. </div>
  271. <div class="pb-3">
  272. <div class="text-xs font-medium text-gray-500 px-2 py-1">
  273. {selectedModelId}
  274. </div>
  275. <div class="border border-gray-50 dark:border-gray-850 w-full px-3 py-2.5 rounded-xl">
  276. <div class="py-0.5">
  277. <!-- $i18n.t('a user') -->
  278. <!-- $i18n.t('an assistant') -->
  279. <textarea
  280. bind:value={message}
  281. class=" w-full h-full bg-transparent resize-none outline-none text-sm"
  282. placeholder={$i18n.t(`Enter {{role}} message here`, {
  283. role: role === 'user' ? $i18n.t('a user') : $i18n.t('an assistant')
  284. })}
  285. on:input={(e) => {
  286. e.target.style.height = '';
  287. e.target.style.height = Math.min(e.target.scrollHeight, 150) + 'px';
  288. }}
  289. on:focus={(e) => {
  290. e.target.style.height = '';
  291. e.target.style.height = Math.min(e.target.scrollHeight, 150) + 'px';
  292. }}
  293. rows="2"
  294. />
  295. </div>
  296. <div class="flex justify-between">
  297. <div>
  298. <button
  299. class="px-3.5 py-1.5 text-sm font-medium bg-gray-50 hover:bg-gray-100 text-gray-900 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition rounded-lg"
  300. on:click={() => {
  301. role = role === 'user' ? 'assistant' : 'user';
  302. }}
  303. >
  304. {#if role === 'user'}
  305. {$i18n.t('User')}
  306. {:else}
  307. {$i18n.t('Assistant')}
  308. {/if}
  309. </button>
  310. </div>
  311. <div>
  312. {#if !loading}
  313. <button
  314. disabled={message === ''}
  315. class="px-3.5 py-1.5 text-sm font-medium disabled:bg-gray-50 dark:disabled:hover:bg-gray-850 disabled:cursor-not-allowed bg-gray-50 hover:bg-gray-100 text-gray-900 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition rounded-lg"
  316. on:click={() => {
  317. addHandler();
  318. role = role === 'user' ? 'assistant' : 'user';
  319. }}
  320. >
  321. {$i18n.t('Add')}
  322. </button>
  323. <button
  324. class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-lg"
  325. on:click={() => {
  326. submitHandler();
  327. }}
  328. >
  329. {$i18n.t('Run')}
  330. </button>
  331. {:else}
  332. <button
  333. class="px-3 py-1.5 text-sm font-medium bg-gray-300 text-black transition rounded-lg"
  334. on:click={() => {
  335. stopResponse();
  336. }}
  337. >
  338. {$i18n.t('Cancel')}
  339. </button>
  340. {/if}
  341. </div>
  342. </div>
  343. </div>
  344. </div>
  345. </div>
  346. </div>
  347. </div>