Browse Source

refac: floating buttons

Timothy Jaeryang Baek 4 months ago
parent
commit
37ce88e744

+ 4 - 0
src/app.css

@@ -56,6 +56,10 @@ math {
 	@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
 }
 
+.markdown-prose-xs {
+	@apply text-xs prose dark:prose-invert prose-headings:font-semibold prose-hr:my-0 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
+}
+
 .markdown a {
 	@apply underline;
 }

+ 32 - 0
src/lib/apis/openai/index.ts

@@ -273,6 +273,38 @@ export const verifyOpenAIConnection = async (
 	return res;
 };
 
+
+export const chatCompletion = async (
+	token: string = '',
+	body: object,
+	url: string = OPENAI_API_BASE_URL
+): Promise<[Response | null, AbortController]> => {
+	const controller = new AbortController();
+	let error = null;
+
+	const res = await fetch(`${url}/chat/completions`, {
+		signal: controller.signal,
+		method: 'POST',
+		headers: {
+			Authorization: `Bearer ${token}`,
+			'Content-Type': 'application/json'
+		},
+		body: JSON.stringify(body)
+	}).catch((err) => {
+		console.log(err);
+		error = err;
+		return null;
+	});
+
+	if (error) {
+		throw error;
+	}
+
+	return [res, controller];
+};
+
+
+
 export const generateOpenAIChatCompletion = async (
 	token: string = '',
 	body: object,

+ 303 - 0
src/lib/components/chat/ContentRenderer/FloatingButtons.svelte

@@ -0,0 +1,303 @@
+<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;
+
+	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
+				}
+			],
+			stream: true // Enable streaming
+		});
+
+		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;
+					}
+
+					// Decode the received chunk
+					const chunk = decoder.decode(value, { stream: true });
+
+					// Process lines within the chunk
+					const lines = chunk.split('\n').filter((line) => line.trim() !== '');
+
+					for (const line of lines) {
+						if (line.startsWith('data: ')) {
+							if (line.startsWith('data: [DONE]')) {
+								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;
+
+										// Scroll to bottom
+										const responseContainer = document.getElementById('response-container');
+										responseContainer.scrollTop = responseContainer.scrollHeight;
+									}
+								} catch (e) {
+									console.error(e);
+								}
+							}
+						}
+					}
+				}
+			};
+
+			// Process the stream in the background
+			await processStream();
+		} else {
+			toast.error('An error occurred while fetching the explanation');
+		}
+	};
+
+	const explainHandler = async () => {
+		if (!model) {
+			toast.error('Model not selected');
+			return;
+		}
+		prompt = `Explain this section to me in more detail\n\n\`\`\`\n${selectedText}\n\`\`\``;
+
+		responseContent = '';
+		const [res, controller] = await chatCompletion(localStorage.token, {
+			model: model,
+			messages: [
+				...messages,
+				{
+					role: 'user',
+					content: prompt
+				}
+			],
+			stream: true // Enable streaming
+		});
+
+		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;
+					}
+
+					// Decode the received chunk
+					const chunk = decoder.decode(value, { stream: true });
+
+					// Process lines within the chunk
+					const lines = chunk.split('\n').filter((line) => line.trim() !== '');
+
+					for (const line of lines) {
+						if (line.startsWith('data: ')) {
+							if (line.startsWith('data: [DONE]')) {
+								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;
+
+										// Scroll to bottom
+										const responseContainer = document.getElementById('response-container');
+										responseContainer.scrollTop = responseContainer.scrollHeight;
+									}
+								} catch (e) {
+									console.error(e);
+								}
+							}
+						}
+					}
+				}
+			};
+
+			// Process the stream in the background
+			await processStream();
+		} else {
+			toast.error('An error occurred while fetching the explanation');
+		}
+	};
+
+	const addHandler = async () => {
+		newMessages = [
+			{
+				role: 'user',
+				content: prompt
+			},
+			{
+				role: 'assistant',
+				content: responseContent
+			}
+		];
+
+		responseContent = null;
+
+		onAdd();
+	};
+
+	export const closeHandler = () => {
+		responseContent = null;
+		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 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">Ask</div>
+				</button>
+				<button
+					class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded 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">Explain</div>
+				</button>
+			</div>
+		{:else}
+			<div
+				class="py-1 flex dark:text-gray-100 bg-gray-50 dark:bg-gray-800 border dark:border-gray-800 w-72 rounded-full shadow-xl"
+			>
+				<input
+					type="text"
+					id="floating-message-input"
+					class="ml-5 bg-transparent outline-none 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}
+				</div>
+			</div>
+		</div>
+	{/if}
+</div>

+ 22 - 113
src/lib/components/chat/Messages/ContentRenderer.svelte

@@ -4,13 +4,13 @@
 	const dispatch = createEventDispatcher();
 
 	import Markdown from './Markdown.svelte';
-	import LightBlub from '$lib/components/icons/LightBlub.svelte';
 	import { chatId, mobile, showArtifacts, showControls, showOverview } from '$lib/stores';
-	import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
-	import { stringify } from 'postcss';
+	import FloatingButtons from '../ContentRenderer/FloatingButtons.svelte';
+	import { createMessagesList } from '$lib/utils';
 
 	export let id;
 	export let content;
+	export let history;
 	export let model = null;
 	export let sources = null;
 
@@ -19,13 +19,11 @@
 	export let onSourceClick = () => {};
 
 	let contentContainerElement;
-	let buttonsContainerElement;
 
-	let selectedText = '';
-	let floatingInput = false;
-	let floatingInputValue = '';
+	let floatingButtonsElement;
 
 	const updateButtonPosition = (event) => {
+		const buttonsContainerElement = document.getElementById(`floating-buttons-${id}`);
 		if (
 			!contentContainerElement?.contains(event.target) &&
 			!buttonsContainerElement?.contains(event.target)
@@ -42,7 +40,6 @@
 			let selection = window.getSelection();
 
 			if (selection.toString().trim().length > 0) {
-				floatingInput = false;
 				const range = selection.getRangeAt(0);
 				const rect = range.getBoundingClientRect();
 
@@ -56,11 +53,10 @@
 					buttonsContainerElement.style.display = 'block';
 
 					// Calculate space available on the right
-					const spaceOnRight = parentRect.width - (left + buttonsContainerElement.offsetWidth);
+					const spaceOnRight = parentRect.width - left;
+					let halfScreenWidth = window.innerWidth / 2;
 
-					let thirdScreenWidth = window.innerWidth / 3;
-
-					if (spaceOnRight < thirdScreenWidth) {
+					if (spaceOnRight < halfScreenWidth) {
 						const right = parentRect.right - rect.right;
 						buttonsContainerElement.style.right = `${right}px`;
 						buttonsContainerElement.style.left = 'auto'; // Reset left
@@ -69,7 +65,6 @@
 						buttonsContainerElement.style.left = `${left}px`;
 						buttonsContainerElement.style.right = 'auto'; // Reset right
 					}
-
 					buttonsContainerElement.style.top = `${top + 5}px`; // +5 to add some spacing
 				}
 			} else {
@@ -79,28 +74,14 @@
 	};
 
 	const closeFloatingButtons = () => {
+		const buttonsContainerElement = document.getElementById(`floating-buttons-${id}`);
 		if (buttonsContainerElement) {
 			buttonsContainerElement.style.display = 'none';
-			selectedText = '';
-			floatingInput = false;
-			floatingInputValue = '';
 		}
-	};
-
-	const selectAskHandler = () => {
-		dispatch('select', {
-			type: 'ask',
-			content: selectedText,
-			input: floatingInputValue
-		});
 
-		floatingInput = false;
-		floatingInputValue = '';
-		selectedText = '';
-
-		// Clear selection
-		window.getSelection().removeAllRanges();
-		buttonsContainerElement.style.display = 'none';
+		if (floatingButtonsElement) {
+			floatingButtonsElement.closeHandler();
+		}
 	};
 
 	const keydownHandler = (e) => {
@@ -176,86 +157,14 @@
 	/>
 </div>
 
-{#if floatingButtons}
-	<div
-		bind:this={buttonsContainerElement}
-		class="absolute rounded-lg mt-1 text-xs z-[9999]"
-		style="display: none"
-	>
-		{#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 flex items-center gap-1 min-w-fit"
-					on:click={() => {
-						selectedText = window.getSelection().toString();
-						floatingInput = true;
-					}}
-				>
-					<ChatBubble className="size-3 shrink-0" />
-
-					<div class="shrink-0">Ask</div>
-				</button>
-				<button
-					class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded flex items-center gap-1 min-w-fit"
-					on:click={() => {
-						const selection = window.getSelection();
-						dispatch('select', {
-							type: 'explain',
-							content: selection.toString()
-						});
-
-						// Clear selection
-						selection.removeAllRanges();
-						buttonsContainerElement.style.display = 'none';
-					}}
-				>
-					<LightBlub className="size-3 shrink-0" />
-
-					<div class="shrink-0">Explain</div>
-				</button>
-			</div>
-		{:else}
-			<div
-				class="py-1 flex dark:text-gray-100 bg-gray-50 dark:bg-gray-800 border dark:border-gray-800 w-72 rounded-full shadow-xl"
-			>
-				<input
-					type="text"
-					class="ml-5 bg-transparent outline-none w-full flex-1 text-sm"
-					placeholder={$i18n.t('Ask a question')}
-					bind:value={floatingInputValue}
-					on:keydown={(e) => {
-						if (e.key === 'Enter') {
-							selectAskHandler();
-						}
-					}}
-				/>
-
-				<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={() => {
-							selectAskHandler();
-						}}
-					>
-						<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}
-	</div>
+{#if floatingButtons && model}
+	<FloatingButtons
+		bind:this={floatingButtonsElement}
+		{id}
+		model={model?.id}
+		messages={createMessagesList(history, id)}
+		onSave={() => {
+			closeFloatingButtons();
+		}}
+	/>
 {/if}

+ 1 - 0
src/lib/components/chat/Messages/ResponseMessage.svelte

@@ -620,6 +620,7 @@
 									<!-- unless message.error === true which is legacy error handling, where the error message is stored in message.content -->
 									<ContentRenderer
 										id={message.id}
+										{history}
 										content={message.content}
 										sources={message.sources}
 										floatingButtons={message?.done}

+ 24 - 8
src/lib/components/chat/Messages/Skeleton.svelte

@@ -1,19 +1,35 @@
+<script lang="ts">
+	export let size = 'md';
+</script>
+
 <div class="w-full mt-2 mb-2">
 	<div class="animate-pulse flex w-full">
-		<div class="space-y-2 w-full">
-			<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded mr-14" />
+		<div class="{size === 'md' ? 'space-y-2' : 'space-y-1.5'} w-full">
+			<div class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded mr-14" />
 
 			<div class="grid grid-cols-3 gap-4">
-				<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-2" />
-				<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1" />
+				<div
+					class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-2"
+				/>
+				<div
+					class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-1"
+				/>
 			</div>
 			<div class="grid grid-cols-4 gap-4">
-				<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1" />
-				<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-2" />
-				<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1 mr-4" />
+				<div
+					class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-1"
+				/>
+				<div
+					class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-2"
+				/>
+				<div
+					class="{size === 'md'
+						? 'h-2'
+						: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-1 mr-4"
+				/>
 			</div>
 
-			<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded" />
+			<div class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded" />
 		</div>
 	</div>
 </div>