|
<script lang="ts"> |
|
import { toast } from 'svelte-sonner'; |
|
|
|
import DOMPurify from 'dompurify'; |
|
import { marked } from 'marked'; |
|
|
|
import { getContext, tick } from 'svelte'; |
|
const i18n = getContext('i18n'); |
|
|
|
import { chatCompletion } from '$lib/apis/openai'; |
|
|
|
import ChatBubble from '$lib/components/icons/ChatBubble.svelte'; |
|
import LightBlub from '$lib/components/icons/LightBlub.svelte'; |
|
import Markdown from '../Messages/Markdown.svelte'; |
|
import Skeleton from '../Messages/Skeleton.svelte'; |
|
|
|
export let id = ''; |
|
export let model = null; |
|
export let messages = []; |
|
export let onAdd = () => {}; |
|
|
|
let floatingInput = false; |
|
|
|
let selectedText = ''; |
|
let floatingInputValue = ''; |
|
|
|
let prompt = ''; |
|
let responseContent = null; |
|
let responseDone = false; |
|
|
|
const autoScroll = async () => { |
|
|
|
const responseContainer = document.getElementById('response-container'); |
|
if ( |
|
responseContainer.scrollHeight - responseContainer.clientHeight <= |
|
responseContainer.scrollTop + 50 |
|
) { |
|
responseContainer.scrollTop = responseContainer.scrollHeight; |
|
} |
|
}; |
|
|
|
const askHandler = async () => { |
|
if (!model) { |
|
toast.error('Model not selected'); |
|
return; |
|
} |
|
prompt = `${floatingInputValue}\n\`\`\`\n${selectedText}\n\`\`\``; |
|
floatingInputValue = ''; |
|
|
|
responseContent = ''; |
|
const [res, controller] = await chatCompletion(localStorage.token, { |
|
model: model, |
|
messages: [ |
|
...messages, |
|
{ |
|
role: 'user', |
|
content: prompt |
|
} |
|
].map((message) => ({ |
|
role: message.role, |
|
content: message.content |
|
})), |
|
stream: true |
|
}); |
|
|
|
if (res && res.ok) { |
|
const reader = res.body.getReader(); |
|
const decoder = new TextDecoder(); |
|
|
|
const processStream = async () => { |
|
while (true) { |
|
|
|
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 { |
|
|
|
try { |
|
const data = JSON.parse(line.slice(6)); |
|
|
|
|
|
if (data.choices && data.choices[0]?.delta?.content) { |
|
responseContent += data.choices[0].delta.content; |
|
|
|
autoScroll(); |
|
} |
|
} catch (e) { |
|
console.error(e); |
|
} |
|
} |
|
} |
|
} |
|
} |
|
}; |
|
|
|
|
|
await processStream(); |
|
} else { |
|
toast.error('An error occurred while fetching the explanation'); |
|
} |
|
}; |
|
|
|
const explainHandler = async () => { |
|
if (!model) { |
|
toast.error('Model not selected'); |
|
return; |
|
} |
|
const explainText = $i18n.t('Explain this section to me in more detail'); |
|
prompt = `${explainText}\n\n\`\`\`\n${selectedText}\n\`\`\``; |
|
|
|
responseContent = ''; |
|
const [res, controller] = await chatCompletion(localStorage.token, { |
|
model: model, |
|
messages: [ |
|
...messages, |
|
{ |
|
role: 'user', |
|
content: prompt |
|
} |
|
].map((message) => ({ |
|
role: message.role, |
|
content: message.content |
|
})), |
|
stream: true |
|
}); |
|
|
|
if (res && res.ok) { |
|
const reader = res.body.getReader(); |
|
const decoder = new TextDecoder(); |
|
|
|
const processStream = async () => { |
|
while (true) { |
|
|
|
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 { |
|
|
|
try { |
|
const data = JSON.parse(line.slice(6)); |
|
|
|
|
|
if (data.choices && data.choices[0]?.delta?.content) { |
|
responseContent += data.choices[0].delta.content; |
|
|
|
autoScroll(); |
|
} |
|
} catch (e) { |
|
console.error(e); |
|
} |
|
} |
|
} |
|
} |
|
} |
|
}; |
|
|
|
|
|
await processStream(); |
|
} else { |
|
toast.error('An error occurred while fetching the explanation'); |
|
} |
|
}; |
|
|
|
const addHandler = async () => { |
|
const messages = [ |
|
{ |
|
role: 'user', |
|
content: prompt |
|
}, |
|
{ |
|
role: 'assistant', |
|
content: responseContent |
|
} |
|
]; |
|
|
|
onAdd({ |
|
modelId: model, |
|
parentId: id, |
|
messages: messages |
|
}); |
|
}; |
|
|
|
export const closeHandler = () => { |
|
responseContent = null; |
|
responseDone = false; |
|
floatingInput = false; |
|
floatingInputValue = ''; |
|
}; |
|
</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 gap-0.5 shrink-0 p-1 bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-lg shadow-xl" |
|
> |
|
<button |
|
class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-sm flex items-center gap-1 min-w-fit" |
|
on:click={async () => { |
|
selectedText = window.getSelection().toString(); |
|
floatingInput = true; |
|
|
|
await tick(); |
|
setTimeout(() => { |
|
const input = document.getElementById('floating-message-input'); |
|
if (input) { |
|
input.focus(); |
|
} |
|
}, 0); |
|
}} |
|
> |
|
<ChatBubble className="size-3 shrink-0" /> |
|
|
|
<div class="shrink-0">{$i18n.t('Ask')}</div> |
|
</button> |
|
<button |
|
class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-sm flex items-center gap-1 min-w-fit" |
|
on:click={() => { |
|
selectedText = window.getSelection().toString(); |
|
explainHandler(); |
|
}} |
|
> |
|
<LightBlub className="size-3 shrink-0" /> |
|
|
|
<div class="shrink-0">{$i18n.t('Explain')}</div> |
|
</button> |
|
</div> |
|
{:else} |
|
<div |
|
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" |
|
> |
|
<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') { |
|
askHandler(); |
|
} |
|
}} |
|
/> |
|
|
|
<div class="ml-1 mr-2"> |
|
<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={() => { |
|
askHandler(); |
|
}} |
|
> |
|
<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-xl shadow-xl w-80 max-w-full"> |
|
<div |
|
class="bg-gray-50/50 dark:bg-gray-800 dark:text-gray-100 text-medium rounded-xl px-3.5 py-3 w-full" |
|
> |
|
<div class="font-medium"> |
|
<Markdown id={`${id}-float-prompt`} content={prompt} /> |
|
</div> |
|
</div> |
|
|
|
<div |
|
class="bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-xl px-3.5 py-3 w-full" |
|
> |
|
<div class=" max-h-80 overflow-y-auto w-full markdown-prose-xs" id="response-container"> |
|
{#if 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> |
|
|