FloatingButtons.svelte 9.3 KB

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