FloatingButtons.svelte 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. <script lang="ts">
  2. import { toast } from 'svelte-sonner';
  3. import DOMPurify from 'dompurify';
  4. import { marked } from 'marked';
  5. import { getContext, tick } from 'svelte';
  6. const i18n = getContext('i18n');
  7. import { chatCompletion } from '$lib/apis/openai';
  8. import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
  9. import LightBlub from '$lib/components/icons/LightBlub.svelte';
  10. import Markdown from '../Messages/Markdown.svelte';
  11. import Skeleton from '../Messages/Skeleton.svelte';
  12. export let id = '';
  13. export let model = null;
  14. export let messages = [];
  15. export let onAdd = () => {};
  16. let floatingInput = false;
  17. let selectedText = '';
  18. let floatingInputValue = '';
  19. let prompt = '';
  20. let responseContent = null;
  21. let responseDone = false;
  22. const autoScroll = async () => {
  23. // Scroll to bottom only if the scroll is at the bottom give 50px buffer
  24. const responseContainer = document.getElementById('response-container');
  25. if (
  26. responseContainer.scrollHeight - responseContainer.clientHeight <=
  27. responseContainer.scrollTop + 50
  28. ) {
  29. responseContainer.scrollTop = responseContainer.scrollHeight;
  30. }
  31. };
  32. const askHandler = async () => {
  33. if (!model) {
  34. toast.error('Model not selected');
  35. return;
  36. }
  37. prompt = `${floatingInputValue}\n\`\`\`\n${selectedText}\n\`\`\``;
  38. floatingInputValue = '';
  39. responseContent = '';
  40. const [res, controller] = await chatCompletion(localStorage.token, {
  41. model: model,
  42. messages: [
  43. ...messages,
  44. {
  45. role: 'user',
  46. content: prompt
  47. }
  48. ].map((message) => ({
  49. role: message.role,
  50. content: message.content
  51. })),
  52. stream: true // Enable streaming
  53. });
  54. if (res && res.ok) {
  55. const reader = res.body.getReader();
  56. const decoder = new TextDecoder();
  57. const processStream = async () => {
  58. while (true) {
  59. // Read data chunks from the response stream
  60. const { done, value } = await reader.read();
  61. if (done) {
  62. break;
  63. }
  64. // Decode the received chunk
  65. const chunk = decoder.decode(value, { stream: true });
  66. // Process lines within the chunk
  67. const lines = chunk.split('\n').filter((line) => line.trim() !== '');
  68. for (const line of lines) {
  69. if (line.startsWith('data: ')) {
  70. if (line.startsWith('data: [DONE]')) {
  71. responseDone = true;
  72. await tick();
  73. autoScroll();
  74. continue;
  75. } else {
  76. // Parse the JSON chunk
  77. try {
  78. const data = JSON.parse(line.slice(6));
  79. // Append the `content` field from the "choices" object
  80. if (data.choices && data.choices[0]?.delta?.content) {
  81. responseContent += data.choices[0].delta.content;
  82. autoScroll();
  83. }
  84. } catch (e) {
  85. console.error(e);
  86. }
  87. }
  88. }
  89. }
  90. }
  91. };
  92. // Process the stream in the background
  93. await processStream();
  94. } else {
  95. toast.error('An error occurred while fetching the explanation');
  96. }
  97. };
  98. const explainHandler = async () => {
  99. if (!model) {
  100. toast.error('Model not selected');
  101. return;
  102. }
  103. prompt = `Explain this section to me in more detail\n\n\`\`\`\n${selectedText}\n\`\`\``;
  104. responseContent = '';
  105. const [res, controller] = await chatCompletion(localStorage.token, {
  106. model: model,
  107. messages: [
  108. ...messages,
  109. {
  110. role: 'user',
  111. content: prompt
  112. }
  113. ].map((message) => ({
  114. role: message.role,
  115. content: message.content
  116. })),
  117. stream: true // Enable streaming
  118. });
  119. if (res && res.ok) {
  120. const reader = res.body.getReader();
  121. const decoder = new TextDecoder();
  122. const processStream = async () => {
  123. while (true) {
  124. // Read data chunks from the response stream
  125. const { done, value } = await reader.read();
  126. if (done) {
  127. break;
  128. }
  129. // Decode the received chunk
  130. const chunk = decoder.decode(value, { stream: true });
  131. // Process lines within the chunk
  132. const lines = chunk.split('\n').filter((line) => line.trim() !== '');
  133. for (const line of lines) {
  134. if (line.startsWith('data: ')) {
  135. if (line.startsWith('data: [DONE]')) {
  136. responseDone = true;
  137. await tick();
  138. autoScroll();
  139. continue;
  140. } else {
  141. // Parse the JSON chunk
  142. try {
  143. const data = JSON.parse(line.slice(6));
  144. // Append the `content` field from the "choices" object
  145. if (data.choices && data.choices[0]?.delta?.content) {
  146. responseContent += data.choices[0].delta.content;
  147. autoScroll();
  148. }
  149. } catch (e) {
  150. console.error(e);
  151. }
  152. }
  153. }
  154. }
  155. }
  156. };
  157. // Process the stream in the background
  158. await processStream();
  159. } else {
  160. toast.error('An error occurred while fetching the explanation');
  161. }
  162. };
  163. const addHandler = async () => {
  164. const messages = [
  165. {
  166. role: 'user',
  167. content: prompt
  168. },
  169. {
  170. role: 'assistant',
  171. content: responseContent
  172. }
  173. ];
  174. onAdd({
  175. modelId: model,
  176. parentId: id,
  177. messages: messages
  178. });
  179. };
  180. export const closeHandler = () => {
  181. responseContent = null;
  182. responseDone = false;
  183. floatingInput = false;
  184. floatingInputValue = '';
  185. };
  186. </script>
  187. <div
  188. id={`floating-buttons-${id}`}
  189. class="absolute rounded-lg mt-1 text-xs z-9999"
  190. style="display: none"
  191. >
  192. {#if responseContent === null}
  193. {#if !floatingInput}
  194. <div
  195. class="flex flex-row gap-0.5 shrink-0 p-1 bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-lg shadow-xl"
  196. >
  197. <button
  198. class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-sm flex items-center gap-1 min-w-fit"
  199. on:click={async () => {
  200. selectedText = window.getSelection().toString();
  201. floatingInput = true;
  202. await tick();
  203. setTimeout(() => {
  204. const input = document.getElementById('floating-message-input');
  205. if (input) {
  206. input.focus();
  207. }
  208. }, 0);
  209. }}
  210. >
  211. <ChatBubble className="size-3 shrink-0" />
  212. <div class="shrink-0">Ask</div>
  213. </button>
  214. <button
  215. class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-sm flex items-center gap-1 min-w-fit"
  216. on:click={() => {
  217. selectedText = window.getSelection().toString();
  218. explainHandler();
  219. }}
  220. >
  221. <LightBlub className="size-3 shrink-0" />
  222. <div class="shrink-0">Explain</div>
  223. </button>
  224. </div>
  225. {:else}
  226. <div
  227. class="py-1 flex dark:text-gray-100 bg-gray-50 dark:bg-gray-800 border dark:border-gray-850 w-72 rounded-full shadow-xl"
  228. >
  229. <input
  230. type="text"
  231. id="floating-message-input"
  232. class="ml-5 bg-transparent outline-hidden w-full flex-1 text-sm"
  233. placeholder={$i18n.t('Ask a question')}
  234. bind:value={floatingInputValue}
  235. on:keydown={(e) => {
  236. if (e.key === 'Enter') {
  237. askHandler();
  238. }
  239. }}
  240. />
  241. <div class="ml-1 mr-2">
  242. <button
  243. class="{floatingInputValue !== ''
  244. ? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
  245. : 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 m-0.5 self-center"
  246. on:click={() => {
  247. askHandler();
  248. }}
  249. >
  250. <svg
  251. xmlns="http://www.w3.org/2000/svg"
  252. viewBox="0 0 16 16"
  253. fill="currentColor"
  254. class="size-4"
  255. >
  256. <path
  257. fill-rule="evenodd"
  258. d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
  259. clip-rule="evenodd"
  260. />
  261. </svg>
  262. </button>
  263. </div>
  264. </div>
  265. {/if}
  266. {:else}
  267. <div class="bg-white dark:bg-gray-850 dark:text-gray-100 rounded-xl shadow-xl w-80 max-w-full">
  268. <div
  269. class="bg-gray-50/50 dark:bg-gray-800 dark:text-gray-100 text-medium rounded-xl px-3.5 py-3 w-full"
  270. >
  271. <div class="font-medium">
  272. <Markdown id={`${id}-float-prompt`} content={prompt} />
  273. </div>
  274. </div>
  275. <div
  276. class="bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-xl px-3.5 py-3 w-full"
  277. >
  278. <div class=" max-h-80 overflow-y-auto w-full markdown-prose-xs" id="response-container">
  279. {#if responseContent.trim() === ''}
  280. <Skeleton size="sm" />
  281. {:else}
  282. <Markdown id={`${id}-float-response`} content={responseContent} />
  283. {/if}
  284. {#if responseDone}
  285. <div class="flex justify-end pt-3 text-sm font-medium">
  286. <button
  287. 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-full"
  288. on:click={addHandler}
  289. >
  290. {$i18n.t('Add')}
  291. </button>
  292. </div>
  293. {/if}
  294. </div>
  295. </div>
  296. </div>
  297. {/if}
  298. </div>