| <script lang="ts"> |
| import { toast } from 'svelte-sonner'; |
| |
| import DOMPurify from 'dompurify'; |
| import { marked } from 'marked'; |
| |
| import { getContext, tick, onDestroy } from 'svelte'; |
| const i18n = getContext('i18n'); |
| |
| import { chatCompletion } from '$lib/apis/openai'; |
| |
| import ChatBubble from '$lib/components/icons/ChatBubble.svelte'; |
| import LightBulb from '$lib/components/icons/LightBulb.svelte'; |
| import Markdown from '../Messages/Markdown.svelte'; |
| import Skeleton from '../Messages/Skeleton.svelte'; |
| import { chatId, models, socket } from '$lib/stores'; |
| |
| export let id = ''; |
| export let messageId = ''; |
| |
| export let model = null; |
| export let messages = []; |
| export let actions = []; |
| export let onAdd = (e) => {}; |
| |
| let floatingInput = false; |
| let selectedAction = null; |
| |
| let selectedText = ''; |
| let floatingInputValue = ''; |
| |
| let content = ''; |
| let responseContent = null; |
| let responseDone = false; |
| let controller = null; |
| |
| $: if (actions.length === 0) { |
| actions = DEFAULT_ACTIONS; |
| } |
| |
| const DEFAULT_ACTIONS = [ |
| { |
| id: 'ask', |
| label: $i18n.t('Ask'), |
| icon: ChatBubble, |
| input: true, |
| prompt: `{{SELECTED_CONTENT}}\n\n\n{{INPUT_CONTENT}}` |
| }, |
| { |
| id: 'explain', |
| label: $i18n.t('Explain'), |
| icon: LightBulb, |
| prompt: `{{SELECTED_CONTENT}}\n\n\n${$i18n.t('Explain')}` |
| } |
| ]; |
| |
| const autoScroll = async () => { |
| const responseContainer = document.getElementById('response-container'); |
| if (responseContainer) { |
| // Scroll to bottom only if the scroll is at the bottom give 50px buffer |
| if ( |
| responseContainer.scrollHeight - responseContainer.clientHeight <= |
| responseContainer.scrollTop + 50 |
| ) { |
| responseContainer.scrollTop = responseContainer.scrollHeight; |
| } |
| } |
| }; |
| |
| const actionHandler = async (actionId) => { |
| if (!model) { |
| toast.error($i18n.t('Model not selected')); |
| return; |
| } |
| |
| let selectedContent = selectedText |
| .split('\n') |
| .map((line) => `> ${line}`) |
| .join('\n'); |
| |
| let selectedAction = actions.find((action) => action.id === actionId); |
| if (!selectedAction) { |
| toast.error($i18n.t('Action not found')); |
| return; |
| } |
| |
| let prompt = selectedAction?.prompt ?? ''; |
| let toolIds = []; |
| |
| {{variableId|tool:id="toolId"}} pattern |
| {{variableId|tool:id="toolId"}} |
| const varToolPattern = /\{\{(.*?)\|tool:id="([^"]+)"\}\}/g; |
| prompt = prompt.replace(varToolPattern, (match, variableId, toolId) => { |
| toolIds.push(toolId); |
| return variableId; // Replace with just variableId |
| }); |
| |
| {{TOOL:toolId}} pattern (for backward compatibility) |
| let toolIdPattern = /\{\{TOOL:([^\}]+)\}\}/g; |
| let match; |
| while ((match = toolIdPattern.exec(prompt)) !== null) { |
| toolIds.push(match[1]); |
| } |
| |
| |
| prompt = prompt.replace(toolIdPattern, ''); |
| |
| if (prompt.includes('{{INPUT_CONTENT}}') && floatingInput) { |
| prompt = prompt.replace('{{INPUT_CONTENT}}', floatingInputValue); |
| floatingInputValue = ''; |
| } |
| |
| prompt = prompt.replace('{{CONTENT}}', selectedText); |
| prompt = prompt.replace('{{SELECTED_CONTENT}}', selectedContent); |
| |
| content = prompt; |
| responseContent = ''; |
| |
| let res; |
| [res, controller] = await chatCompletion(localStorage.token, { |
| model: model, |
| model_item: $models.find((m) => m.id === model), |
| session_id: $socket?.id, |
| chat_id: $chatId, |
| messages: [ |
| ...messages, |
| { |
| role: 'user', |
| content: content |
| } |
| ].map((message) => ({ |
| role: message.role, |
| content: message.content |
| })), |
| ...(toolIds.length > 0 |
| ? { |
| tool_ids: toolIds |
| // params: { |
| // function_calling: 'native' |
| // } |
| } |
| : {}), |
| |
| stream: true |
| }); |
| |
| if (res && res.ok) { |
| const reader = res.body.getReader(); |
| const decoder = new TextDecoder(); |
| |
| const processStream = async () => { |
| while (true) { |
| // Read data chunks from the response stream |
| const { done, value } = await reader.read(); |
| if (done) { |
| break; |
| } |
| |
| |
| const chunk = decoder.decode(value, { stream: true }); |
| |
| |
| const lines = chunk.split('\n').filter((line) => line.trim() !== ''); |
| |
| for (const line of lines) { |
| if (line.startsWith('data: ')) { |
| if (line.startsWith('data: [DONE]')) { |
| responseDone = true; |
| |
| await tick(); |
| autoScroll(); |
| continue; |
| } else { |
| // Parse the JSON chunk |
| try { |
| const data = JSON.parse(line.slice(6)); |
| |
| // Append the `content` field from the "choices" object |
| if (data.choices && data.choices[0]?.delta?.content) { |
| responseContent += data.choices[0].delta.content; |
| |
| autoScroll(); |
| } |
| } catch (e) { |
| console.error(e); |
| } |
| } |
| } |
| } |
| } |
| }; |
| |
| |
| try { |
| await processStream(); |
| } catch (e) { |
| if (e.name !== 'AbortError') { |
| console.error(e); |
| } |
| } |
| } else { |
| toast.error($i18n.t('An error occurred while fetching the explanation')); |
| } |
| }; |
| |
| const addHandler = async () => { |
| const messages = [ |
| { |
| role: 'user', |
| content: content |
| }, |
| { |
| role: 'assistant', |
| content: responseContent |
| } |
| ]; |
| |
| onAdd({ |
| modelId: model, |
| parentId: messageId, |
| messages: messages |
| }); |
| }; |
| |
| export const closeHandler = () => { |
| if (controller) { |
| controller.abort(); |
| } |
| |
| selectedAction = null; |
| selectedText = ''; |
| responseContent = null; |
| responseDone = false; |
| floatingInput = false; |
| floatingInputValue = ''; |
| }; |
| |
| onDestroy(() => { |
| if (controller) { |
| controller.abort(); |
| } |
| }); |
| </script> |
|
|
| <div |
| id={`floating-buttons-${id}`} |
| class="absolute rounded-lg mt-1 text-xs z-9999" |
| style="display: none" |
| > |
| {#if responseContent === null} |
| {#if !floatingInput} |
| <div |
| 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" |
| > |
| {#each actions as action} |
| <button |
| 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" |
| on:click={async () => { |
| selectedText = window.getSelection().toString(); |
| selectedAction = action; |
|
|
| if (action.prompt.includes('{{INPUT_CONTENT}}')) { |
| floatingInput = true; |
| floatingInputValue = ''; |
|
|
| await tick(); |
| setTimeout(() => { |
| const input = document.getElementById('floating-message-input'); |
| if (input) { |
| input.focus(); |
| } |
| }, 0); |
| } else { |
| actionHandler(action.id); |
| } |
| }} |
| > |
| {#if action.icon} |
| <svelte:component this={action.icon} className="size-3 shrink-0" /> |
| {/if} |
| <div class="shrink-0">{action.label}</div> |
| </button> |
| {/each} |
| </div> |
| {:else} |
| <div |
| class="py-1 flex dark:text-gray-100 bg-white dark:bg-gray-850 border border-gray-100 dark:border-gray-800 w-72 rounded-full shadow-xl" |
| > |
| <input |
| type="text" |
| id="floating-message-input" |
| class="ml-5 bg-transparent outline-hidden w-full flex-1 text-sm" |
| placeholder={$i18n.t('Ask a question')} |
| bind:value={floatingInputValue} |
| on:keydown={(e) => { |
| if (e.key === 'Enter') { |
| actionHandler(selectedAction?.id); |
| } |
| }} |
| /> |
|
|
| <div class="ml-1 mr-1"> |
| <button |
| class="{floatingInputValue !== '' |
| ? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 ' |
| : '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" |
| on:click={() => { |
| actionHandler(selectedAction?.id); |
| }} |
| > |
| <svg |
| xmlns="http://www.w3.org/2000/svg" |
| viewBox="0 0 16 16" |
| fill="currentColor" |
| class="size-4" |
| > |
| <path |
| fill-rule="evenodd" |
| 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" |
| clip-rule="evenodd" |
| /> |
| </svg> |
| </button> |
| </div> |
| </div> |
| {/if} |
| {:else} |
| <div |
| 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" |
| > |
| <div |
| class="bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-3xl px-3.5 pt-3 w-full" |
| > |
| <div class="font-medium"> |
| <Markdown id={`${id}-float-prompt`} {content} /> |
| </div> |
| </div> |
|
|
| <div class="bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-4xl w-full"> |
| <div |
| class=" max-h-80 overflow-y-auto w-full markdown-prose-xs px-3.5 py-3" |
| id="response-container" |
| > |
| {#if !responseContent || responseContent?.trim() === ''} |
| <Skeleton size="sm" /> |
| {:else} |
| <Markdown id={`${id}-float-response`} content={responseContent} /> |
| {/if} |
| |
| {#if responseDone} |
| <div class="flex justify-end pt-3 text-sm font-medium"> |
| <button |
| 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" |
| on:click={addHandler} |
| > |
| {$i18n.t('Add')} |
| </button> |
| </div> |
| {/if} |
| </div> |
| </div> |
| </div> |
| {/if} |
| </div> |
|
|