FloatingButtons.svelte 7.9 KB

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