Browse Source

feat: artifacts

Co-Authored-By: Andrew Tait Gehrhardt <134739775+atgehrhardt@users.noreply.github.com>
Timothy J. Baek 7 months ago
parent
commit
de59ecf8a3

+ 212 - 0
src/lib/components/chat/Artifacts.svelte

@@ -0,0 +1,212 @@
+<script lang="ts">
+	import { toast } from 'svelte-sonner';
+	import { onMount, getContext, createEventDispatcher } from 'svelte';
+	const i18n = getContext('i18n');
+	const dispatch = createEventDispatcher();
+
+	import { showArtifacts, showControls } from '$lib/stores';
+	import XMark from '../icons/XMark.svelte';
+
+	export let messages;
+	export let overlay = false;
+
+	let contents: Array<{ content: string }> = [];
+	let selectedContentIdx = 0;
+
+	let iframeElement: HTMLIFrameElement;
+
+	$: if (messages) {
+		getContents();
+	}
+
+	function getContents() {
+		contents = [];
+		messages.forEach((message) => {
+			if (message.content) {
+				let htmlContent = '';
+				let cssContent = '';
+				let jsContent = '';
+
+				const codeBlocks = message.content.match(/```[\s\S]*?```/g);
+				if (codeBlocks) {
+					codeBlocks.forEach((block) => {
+						const lang = block.split('\n')[0].replace('```', '').trim().toLowerCase();
+						const code = block.replace(/```[\s\S]*?\n/, '').replace(/```$/, '');
+						if (lang === 'html') {
+							htmlContent += code + '\n';
+						} else if (lang === 'css') {
+							cssContent += code + '\n';
+						} else if (lang === 'javascript' || lang === 'js') {
+							jsContent += code + '\n';
+						}
+					});
+				}
+
+				const inlineHtml = message.content.match(/<html>[\s\S]*?<\/html>/gi);
+				const inlineCss = message.content.match(/<style>[\s\S]*?<\/style>/gi);
+				const inlineJs = message.content.match(/<script>[\s\S]*?<\/script>/gi);
+
+				if (inlineHtml) htmlContent += inlineHtml.join('\n');
+				if (inlineCss) cssContent += inlineCss.join('\n');
+				if (inlineJs) jsContent += inlineJs.join('\n');
+
+				if (htmlContent || cssContent || jsContent) {
+					const renderedContent = `
+                        <!DOCTYPE html>
+                        <html lang="en">
+                        <head>
+                            <meta charset="UTF-8">
+                            <meta name="viewport" content="width=device-width, initial-scale=1.0">
+                            ${cssContent}
+							<${''}style>
+								body {
+									background-color: white; /* Ensure the iframe has a white background */
+								}
+							</${''}style>
+                        </head>
+                        <body>
+                            ${htmlContent}
+                            ${jsContent}
+                        </body>
+                        </html>
+                    `;
+					contents = [...contents, { content: renderedContent }];
+				}
+			}
+		});
+
+		selectedContentIdx = contents ? contents.length - 1 : 0;
+	}
+
+	function navigateContent(direction: 'prev' | 'next') {
+		console.log(selectedContentIdx);
+
+		selectedContentIdx =
+			direction === 'prev'
+				? Math.max(selectedContentIdx - 1, 0)
+				: Math.min(selectedContentIdx + 1, contents.length - 1);
+
+		console.log(selectedContentIdx);
+	}
+
+	const iframeLoadHandler = () => {
+		iframeElement.contentWindow.addEventListener(
+			'click',
+			function (e) {
+				const target = e.target.closest('a');
+				if (target && target.href) {
+					e.preventDefault();
+					const url = new URL(target.href, iframeElement.baseURI);
+					if (url.origin === window.location.origin) {
+						iframeElement.contentWindow.history.pushState(
+							null,
+							'',
+							url.pathname + url.search + url.hash
+						);
+					} else {
+						console.log('External navigation blocked:', url.href);
+					}
+				}
+			},
+			true
+		);
+
+		// Cancel drag when hovering over iframe
+		iframeElement.contentWindow.addEventListener('mouseenter', function (e) {
+			e.preventDefault();
+			iframeElement.contentWindow.addEventListener('dragstart', (event) => {
+				event.preventDefault();
+			});
+		});
+	};
+</script>
+
+<div class=" w-full h-full relative flex flex-col bg-gray-850">
+	<div class="w-full h-full flex-1 relative">
+		{#if overlay}
+			<div class=" absolute top-0 left-0 right-0 bottom-0 z-10"></div>
+		{/if}
+
+		<div class=" absolute z-50 w-full flex items-center justify-end p-4 dark:text-gray-100">
+			<button
+				class="self-center"
+				on:click={() => {
+					dispatch('close');
+					showControls.set(false);
+					showArtifacts.set(false);
+				}}
+			>
+				<XMark className="size-4" />
+			</button>
+		</div>
+
+		<div class="flex-1 w-full h-full">
+			<div class=" h-full flex flex-col">
+				{#if contents.length > 0}
+					<div class="max-w-full w-full h-full">
+						<iframe
+							bind:this={iframeElement}
+							title="Content"
+							srcdoc={contents[selectedContentIdx].content}
+							class="w-full border-0 h-full rounded-none"
+							sandbox="allow-scripts allow-forms allow-same-origin"
+							on:load={iframeLoadHandler}
+						></iframe>
+					</div>
+				{:else}
+					<div class="m-auto text-xs">No HTML, CSS, or JavaScript content found.</div>
+				{/if}
+			</div>
+		</div>
+	</div>
+
+	{#if contents.length > 0}
+		<div class="flex justify-between items-center p-2.5 font-primary">
+			<div class="flex items-center space-x-2">
+				<div class="flex self-center min-w-fit" dir="ltr">
+					<button
+						class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition disabled:cursor-not-allowed"
+						on:click={() => navigateContent('prev')}
+						disabled={contents.length <= 1}
+					>
+						<svg
+							xmlns="http://www.w3.org/2000/svg"
+							fill="none"
+							viewBox="0 0 24 24"
+							stroke="currentColor"
+							stroke-width="2.5"
+							class="size-3.5"
+						>
+							<path
+								stroke-linecap="round"
+								stroke-linejoin="round"
+								d="M15.75 19.5 8.25 12l7.5-7.5"
+							/>
+						</svg>
+					</button>
+
+					<div class="text-xs self-center dark:text-gray-100 min-w-fit">
+						Version {selectedContentIdx + 1} of {contents.length}
+					</div>
+
+					<button
+						class="self-center p-1 hover:bg-black/5 dark:hover:bg-white/5 dark:hover:text-white hover:text-black rounded-md transition disabled:cursor-not-allowed"
+						on:click={() => navigateContent('next')}
+						disabled={contents.length <= 1}
+					>
+						<svg
+							xmlns="http://www.w3.org/2000/svg"
+							fill="none"
+							viewBox="0 0 24 24"
+							stroke="currentColor"
+							stroke-width="2.5"
+							class="size-3.5"
+						>
+							<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
+						</svg>
+					</button>
+				</div>
+			</div>
+		</div>
+	{/if}
+</div>

+ 1 - 0
src/lib/components/chat/Chat.svelte

@@ -2088,6 +2088,7 @@
 				bind:files
 				bind:pane={controlPane}
 				chatId={$chatId}
+				messages={createMessagesList(history.currentId)}
 				modelId={selectedModelIds?.at(0) ?? null}
 				models={selectedModelIds.reduce((a, e, i, arr) => {
 					const model = $models.find((m) => m.id === e);

+ 50 - 29
src/lib/components/chat/ChatControls.svelte

@@ -3,7 +3,7 @@
 	import { slide } from 'svelte/transition';
 
 	import { onDestroy, onMount, tick } from 'svelte';
-	import { mobile, showControls, showCallOverlay, showOverview } from '$lib/stores';
+	import { mobile, showControls, showCallOverlay, showOverview, showArtifacts } from '$lib/stores';
 
 	import Modal from '../common/Modal.svelte';
 	import Controls from './Controls/Controls.svelte';
@@ -12,12 +12,14 @@
 	import Overview from './Overview.svelte';
 	import { Pane, PaneResizer } from 'paneforge';
 	import EllipsisVertical from '../icons/EllipsisVertical.svelte';
-	import { get } from 'svelte/store';
+	import Artifacts from './Artifacts.svelte';
 
 	export let history;
 	export let models = [];
 
 	export let chatId = null;
+	export let messages = [];
+
 	export let chatFiles = [];
 	export let params = {};
 
@@ -29,44 +31,57 @@
 	export let modelId;
 
 	export let pane;
-	let largeScreen = false;
 
-	onMount(() => {
-		// listen to resize 1024px
-		const mediaQuery = window.matchMedia('(min-width: 1024px)');
+	let mediaQuery;
+	let largeScreen = false;
+	let dragged = false;
 
-		const handleMediaQuery = async (e) => {
-			if (e.matches) {
-				largeScreen = true;
+	const handleMediaQuery = async (e) => {
+		if (e.matches) {
+			largeScreen = true;
 
-				if ($showCallOverlay) {
-					showCallOverlay.set(false);
-					await tick();
-					showCallOverlay.set(true);
-				}
-			} else {
-				largeScreen = false;
+			if ($showCallOverlay) {
+				showCallOverlay.set(false);
+				await tick();
+				showCallOverlay.set(true);
+			}
+		} else {
+			largeScreen = false;
 
-				if ($showCallOverlay) {
-					showCallOverlay.set(false);
-					await tick();
-					showCallOverlay.set(true);
-				}
-				pane = null;
+			if ($showCallOverlay) {
+				showCallOverlay.set(false);
+				await tick();
+				showCallOverlay.set(true);
 			}
-		};
+			pane = null;
+		}
+	};
 
-		mediaQuery.addEventListener('change', handleMediaQuery);
+	const onMouseDown = (event) => {
+		dragged = true;
+	};
+
+	const onMouseUp = (event) => {
+		dragged = false;
+	};
 
+	onMount(() => {
+		// listen to resize 1024px
+		mediaQuery = window.matchMedia('(min-width: 1024px)');
+
+		mediaQuery.addEventListener('change', handleMediaQuery);
 		handleMediaQuery(mediaQuery);
 
-		return () => {
-			mediaQuery.removeEventListener('change', handleMediaQuery);
-		};
+		document.addEventListener('mousedown', onMouseDown);
+		document.addEventListener('mouseup', onMouseUp);
 	});
 
 	onDestroy(() => {
 		showControls.set(false);
+
+		mediaQuery.removeEventListener('change', handleMediaQuery);
+		document.removeEventListener('mousedown', onMouseDown);
+		document.removeEventListener('mouseup', onMouseUp);
 	});
 
 	$: if (!chatId) {
@@ -84,7 +99,9 @@
 				}}
 			>
 				<div
-					class=" {$showCallOverlay || $showOverview ? ' h-screen  w-screen' : 'px-6 py-4'} h-full"
+					class=" {$showCallOverlay || $showOverview || $showArtifacts
+						? ' h-screen  w-screen'
+						: 'px-6 py-4'} h-full"
 				>
 					{#if $showCallOverlay}
 						<div
@@ -102,6 +119,8 @@
 								}}
 							/>
 						</div>
+					{:else if $showArtifacts}
+						<Artifacts {messages} />
 					{:else if $showOverview}
 						<Overview
 							{history}
@@ -157,7 +176,7 @@
 			{#if $showControls}
 				<div class="pr-4 pb-8 flex max-h-full min-h-full">
 					<div
-						class="w-full {$showOverview && !$showCallOverlay
+						class="w-full {($showOverview || $showArtifacts) && !$showCallOverlay
 							? ' '
 							: 'px-5 py-4 bg-white dark:shadow-lg dark:bg-gray-850  border border-gray-50 dark:border-gray-800'}  rounded-lg z-50 pointer-events-auto overflow-y-auto scrollbar-hidden"
 					>
@@ -175,6 +194,8 @@
 									}}
 								/>
 							</div>
+						{:else if $showArtifacts}
+							<Artifacts {messages} overlay={dragged} />
 						{:else if $showOverview}
 							<Overview
 								{history}

+ 19 - 0
src/lib/components/icons/Cube.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'size-4';
+	export let strokeWidth = '2';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path
+		stroke-linecap="round"
+		stroke-linejoin="round"
+		d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
+	/>
+</svg>

+ 15 - 3
src/lib/components/layout/Navbar/Menu.svelte

@@ -1,4 +1,5 @@
 <script lang="ts">
+	import { toast } from 'svelte-sonner';
 	import { DropdownMenu } from 'bits-ui';
 	import { getContext } from 'svelte';
 
@@ -8,16 +9,15 @@
 	import { downloadChatAsPDF } from '$lib/apis/utils';
 	import { copyToClipboard, createMessagesList } from '$lib/utils';
 
-	import { showOverview, showControls, mobile } from '$lib/stores';
+	import { showOverview, showControls, showArtifacts, mobile } from '$lib/stores';
 	import { flyAndScale } from '$lib/utils/transitions';
 
 	import Dropdown from '$lib/components/common/Dropdown.svelte';
 	import Tags from '$lib/components/chat/Tags.svelte';
 	import Map from '$lib/components/icons/Map.svelte';
-	import { get } from 'svelte/store';
 	import Clipboard from '$lib/components/icons/Clipboard.svelte';
-	import { toast } from 'svelte-sonner';
 	import AdjustmentsHorizontal from '$lib/components/icons/AdjustmentsHorizontal.svelte';
+	import Cube from '$lib/components/icons/Cube.svelte';
 
 	const i18n = getContext('i18n');
 
@@ -156,6 +156,18 @@
 				<div class="flex items-center">{$i18n.t('Overview')}</div>
 			</DropdownMenu.Item>
 
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-2 text-sm  cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				id="chat-overview-button"
+				on:click={async () => {
+					await showControls.set(true);
+					await showArtifacts.set(true);
+				}}
+			>
+				<Cube className=" size-4" strokeWidth="1.5" />
+				<div class="flex items-center">{$i18n.t('Artifacts')}</div>
+			</DropdownMenu.Item>
+
 			<DropdownMenu.Item
 				class="flex gap-2 items-center px-3 py-2 text-sm  cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 				id="chat-copy-button"

+ 1 - 0
src/lib/stores/index.ts

@@ -45,6 +45,7 @@ export const showChangelog = writable(false);
 
 export const showControls = writable(false);
 export const showOverview = writable(false);
+export const showArtifacts = writable(false);
 export const showCallOverlay = writable(false);
 
 export const temporaryChatEnabled = writable(false);