Timothy J. Baek 7 месяцев назад
Родитель
Сommit
babfc97c90

+ 34 - 0
package-lock.json

@@ -34,6 +34,7 @@
 				"marked": "^9.1.0",
 				"mermaid": "^10.9.1",
 				"paneforge": "^0.0.6",
+				"panzoom": "^9.4.3",
 				"pyodide": "^0.26.1",
 				"socket.io-client": "^4.2.0",
 				"sortablejs": "^1.15.2",
@@ -2435,6 +2436,14 @@
 				"url": "https://github.com/sponsors/epoberezkin"
 			}
 		},
+		"node_modules/amator": {
+			"version": "1.1.0",
+			"resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz",
+			"integrity": "sha512-V5+aH8pe+Z3u/UG3L3pG3BaFQGXAyXHVQDroRwjPHdh08bcUEchAVsU1MCuJSCaU5o60wTK6KaE6te5memzgYw==",
+			"dependencies": {
+				"bezier-easing": "^2.0.3"
+			}
+		},
 		"node_modules/ansi-colors": {
 			"version": "4.1.3",
 			"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -2725,6 +2734,11 @@
 				"tweetnacl": "^0.14.3"
 			}
 		},
+		"node_modules/bezier-easing": {
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz",
+			"integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig=="
+		},
 		"node_modules/binary-extensions": {
 			"version": "2.3.0",
 			"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -7178,6 +7192,11 @@
 			"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
 			"dev": true
 		},
+		"node_modules/ngraph.events": {
+			"version": "1.2.2",
+			"resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.2.2.tgz",
+			"integrity": "sha512-JsUbEOzANskax+WSYiAPETemLWYXmixuPAlmZmhIbIj6FH/WDgEGCGnRwUQBK0GjOnVm8Ui+e5IJ+5VZ4e32eQ=="
+		},
 		"node_modules/node-releases": {
 			"version": "2.0.14",
 			"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
@@ -7375,6 +7394,16 @@
 				"svelte": "^4.0.0 || ^5.0.0-next.1"
 			}
 		},
+		"node_modules/panzoom": {
+			"version": "9.4.3",
+			"resolved": "https://registry.npmjs.org/panzoom/-/panzoom-9.4.3.tgz",
+			"integrity": "sha512-xaxCpElcRbQsUtIdwlrZA90P90+BHip4Vda2BC8MEb4tkI05PmR6cKECdqUCZ85ZvBHjpI9htJrZBxV5Gp/q/w==",
+			"dependencies": {
+				"amator": "^1.1.0",
+				"ngraph.events": "^1.2.2",
+				"wheel": "^1.0.0"
+			}
+		},
 		"node_modules/parent-module": {
 			"version": "1.0.1",
 			"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -10481,6 +10510,11 @@
 			"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.3.0.tgz",
 			"integrity": "sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA=="
 		},
+		"node_modules/wheel": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz",
+			"integrity": "sha512-XiCMHibOiqalCQ+BaNSwRoZ9FDTAvOsXxGHXChBugewDj7HC8VBIER71dEOiRH1fSdLbRCQzngKTSiZ06ZQzeA=="
+		},
 		"node_modules/which": {
 			"version": "2.0.2",
 			"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

+ 1 - 0
package.json

@@ -74,6 +74,7 @@
 		"marked": "^9.1.0",
 		"mermaid": "^10.9.1",
 		"paneforge": "^0.0.6",
+		"panzoom": "^9.4.3",
 		"pyodide": "^0.26.1",
 		"socket.io-client": "^4.2.0",
 		"sortablejs": "^1.15.2",

+ 46 - 23
src/lib/components/chat/Artifacts.svelte

@@ -9,12 +9,13 @@
 	import { copyToClipboard, createMessagesList } from '$lib/utils';
 	import ArrowsPointingOut from '../icons/ArrowsPointingOut.svelte';
 	import Tooltip from '../common/Tooltip.svelte';
+	import SvgPanZoom from '../common/SVGPanZoom.svelte';
 
 	export let overlay = false;
 	export let history;
 	let messages = [];
 
-	let contents: Array<{ content: string }> = [];
+	let contents: Array<{ type: string; content: string }> = [];
 	let selectedContentIdx = 0;
 
 	let copied = false;
@@ -32,25 +33,33 @@
 		contents = [];
 		messages.forEach((message) => {
 			if (message.content) {
-				let htmlContent = '';
-				let cssContent = '';
-				let jsContent = '';
+				const codeBlockContents = message.content.match(/```[\s\S]*?```/g);
+				let codeBlocks = [];
 
-				const codeBlocks = message.content.match(/```[\s\S]*?```/g);
-				if (codeBlocks) {
-					codeBlocks.forEach((block) => {
+				if (codeBlockContents) {
+					codeBlockContents.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';
-						}
+						codeBlocks.push({ lang, code });
 					});
 				}
 
+				let htmlContent = '';
+				let cssContent = '';
+				let jsContent = '';
+
+				codeBlocks.forEach((block) => {
+					const { lang, code } = block;
+
+					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);
@@ -98,7 +107,14 @@
                         </body>
                         </html>
                     `;
-					contents = [...contents, { content: renderedContent }];
+					contents = [...contents, { type: 'iframe', content: renderedContent }];
+				} else {
+					// Check for SVG content
+					for (const block of codeBlocks) {
+						if (block.lang === 'svg' || (block.lang === 'xml' && block.code.includes('<svg'))) {
+							contents = [...contents, { type: 'svg', content: block.code }];
+						}
+					}
 				}
 			}
 		});
@@ -184,14 +200,21 @@
 			<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>
+						{#if contents[selectedContentIdx].type === 'iframe'}
+							<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>
+						{:else if contents[selectedContentIdx].type === 'svg'}
+							<SvgPanZoom
+								className=" w-full h-full max-h-full overflow-hidden"
+								svg={contents[selectedContentIdx].content}
+							/>
+						{/if}
 					</div>
 				{:else}
 					<div class="m-auto font-medium text-xs text-gray-900 dark:text-white">

+ 11 - 3
src/lib/components/chat/Messages/CodeBlock.svelte

@@ -12,6 +12,7 @@
 
 	import PyodideWorker from '$lib/workers/pyodide.worker?worker';
 	import CodeEditor from '$lib/components/common/CodeEditor.svelte';
+	import SvgPanZoom from '$lib/components/common/SVGPanZoom.svelte';
 
 	const i18n = getContext('i18n');
 	const dispatch = createEventDispatcher();
@@ -271,14 +272,18 @@ __builtins__.input = input`);
 	}
 
 	$: if (lang) {
-		dispatch('code', { lang });
+		dispatchCode();
 	}
 
+	const dispatchCode = () => {
+		dispatch('code', { lang, code });
+	};
+
 	onMount(async () => {
 		console.log('codeblock', lang, code);
 
 		if (lang) {
-			dispatch('code', { lang });
+			dispatchCode();
 		}
 		if (document.documentElement.classList.contains('dark')) {
 			mermaid.initialize({
@@ -300,7 +305,10 @@ __builtins__.input = input`);
 	<div class="relative my-2 flex flex-col rounded-lg" dir="ltr">
 		{#if lang === 'mermaid'}
 			{#if mermaidHtml}
-				{@html `${mermaidHtml}`}
+				<SvgPanZoom
+					className=" border border-gray-50 dark:border-gray-850 rounded-lg max-h-fit overflow-hidden"
+					svg={mermaidHtml}
+				/>
 			{:else}
 				<pre class="mermaid">{code}</pre>
 			{/if}

+ 8 - 3
src/lib/components/chat/Messages/ContentRenderer.svelte

@@ -71,9 +71,14 @@
 			dispatch('update', e.detail);
 		}}
 		on:code={(e) => {
-			const { lang } = e.detail;
-			console.log('code', lang);
-			if (['html', 'svg'].includes(lang) && !$mobile) {
+			const { lang, code } = e.detail;
+			console.log('lang', lang);
+			console.log('code', code);
+
+			if (
+				(['html', 'svg'].includes(lang) || (lang === 'xml' && code.includes('svg'))) &&
+				!$mobile
+			) {
 				showArtifacts.set(true);
 				showControls.set(true);
 			}

+ 29 - 0
src/lib/components/common/SVGPanZoom.svelte

@@ -0,0 +1,29 @@
+<script lang="ts">
+	import { onMount } from 'svelte';
+	import panzoom from 'panzoom';
+
+	import DOMPurify from 'dompurify';
+
+	export let className = '';
+	export let svg = '';
+
+	let instance;
+
+	let sceneParentElement: HTMLElement;
+	let sceneElement: HTMLElement;
+
+	$: if (sceneElement) {
+		instance = panzoom(sceneElement, {
+			bounds: true,
+			boundsPadding: 0.1,
+
+			zoomSpeed: 0.065
+		});
+	}
+</script>
+
+<div bind:this={sceneParentElement} class={className}>
+	<div bind:this={sceneElement} class="flex h-full max-h-full justify-center items-center">
+		{@html svg}
+	</div>
+</div>