Spaces:
				
			
			
	
			
			
		Runtime error
		
	
	
	
			
			
	
	
	
	
		
		
		Runtime error
		
	UX adjustments (#76)
Browse files- Fix bug where `new message` is not reachable
- Simplify conversation scroll wrapper logic, avoiding magic `height`
calculations
- Fix some mobile alignments
- Make it so model selector modal is visible even when virtual keyboard
is shown
- Align tokens and latency to conversation window
- Fixes issues where conversation comparisons were not being saved
---------
Co-authored-by: Victor Muštar (aider) <victor.mustar@gmail.com>
- src/app.css +7 -0
- src/lib/actions/autofocus.ts +11 -4
- src/lib/components/inference-playground/checkpoints-menu.svelte +11 -5
- src/lib/components/inference-playground/code-snippets.svelte +3 -4
- src/lib/components/inference-playground/conversation.svelte +2 -5
- src/lib/components/inference-playground/custom-model-config.svelte +48 -12
- src/lib/components/inference-playground/message.svelte +3 -4
- src/lib/components/inference-playground/model-selector-modal.svelte +121 -123
- src/lib/components/inference-playground/playground.svelte +89 -71
- src/lib/components/share-modal.svelte +1 -1
- src/lib/components/tooltip.svelte +11 -3
- src/lib/state/session.svelte.ts +16 -14
- src/lib/utils/form.svelte.ts +35 -0
- src/lib/utils/url.ts +8 -0
- vite.config.ts +5 -0
    	
        src/app.css
    CHANGED
    
    | @@ -41,6 +41,13 @@ | |
| 41 | 
             
            	}
         | 
| 42 | 
             
            }
         | 
| 43 |  | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 44 | 
             
            /* Utilities */
         | 
| 45 | 
             
            @utility abs-x-center {
         | 
| 46 | 
             
            	left: 50%;
         | 
|  | |
| 41 | 
             
            	}
         | 
| 42 | 
             
            }
         | 
| 43 |  | 
| 44 | 
            +
            /* Custom variants */
         | 
| 45 | 
            +
            @custom-variant nd {
         | 
| 46 | 
            +
            	&:not(:disabled) {
         | 
| 47 | 
            +
            		@slot;
         | 
| 48 | 
            +
            	}
         | 
| 49 | 
            +
            }
         | 
| 50 | 
            +
             | 
| 51 | 
             
            /* Utilities */
         | 
| 52 | 
             
            @utility abs-x-center {
         | 
| 53 | 
             
            	left: 50%;
         | 
    	
        src/lib/actions/autofocus.ts
    CHANGED
    
    | @@ -1,7 +1,14 @@ | |
| 1 | 
             
            import { tick } from "svelte";
         | 
| 2 |  | 
| 3 | 
            -
            export function autofocus(node: HTMLElement) {
         | 
| 4 | 
            -
            	 | 
| 5 | 
            -
            		 | 
| 6 | 
            -
             | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 7 | 
             
            }
         | 
|  | |
| 1 | 
             
            import { tick } from "svelte";
         | 
| 2 |  | 
| 3 | 
            +
            export function autofocus(node: HTMLElement, enabled = true) {
         | 
| 4 | 
            +
            	function update(enabled = true) {
         | 
| 5 | 
            +
            		if (enabled) {
         | 
| 6 | 
            +
            			tick().then(() => {
         | 
| 7 | 
            +
            				node.focus();
         | 
| 8 | 
            +
            			});
         | 
| 9 | 
            +
            		}
         | 
| 10 | 
            +
            	}
         | 
| 11 | 
            +
            	update(enabled);
         | 
| 12 | 
            +
             | 
| 13 | 
            +
            	return { update };
         | 
| 14 | 
             
            }
         | 
    	
        src/lib/components/inference-playground/checkpoints-menu.svelte
    CHANGED
    
    | @@ -72,8 +72,8 @@ | |
| 72 | 
             
            									<IconCompare class="text-xs text-gray-400" />
         | 
| 73 | 
             
            								{/if}
         | 
| 74 | 
             
            								{#each state.conversations as { messages }, i}
         | 
| 75 | 
            -
            									<span class={["text-gray-200"]}>
         | 
| 76 | 
            -
            										{messages.length} messages
         | 
| 77 | 
             
            									</span>
         | 
| 78 | 
             
            									{#if multiple && i === 0}
         | 
| 79 | 
             
            										<span class="text-gray-500">|</span>
         | 
| @@ -108,13 +108,15 @@ | |
| 108 |  | 
| 109 | 
             
            					{#if tooltip.open}
         | 
| 110 | 
             
            						<div
         | 
| 111 | 
            -
            							class={[ | 
|  | |
|  | |
| 112 | 
             
            							{...tooltip.content}
         | 
| 113 | 
             
            							transition:fly={{ x: -2 }}
         | 
| 114 | 
             
            						>
         | 
| 115 | 
             
            							<div class="size-4 rounded-tl border-t border-l border-gray-700" {...tooltip.arrow}></div>
         | 
| 116 | 
             
            							{#each state.conversations as conversation, i}
         | 
| 117 | 
            -
            								{@const msgs = conversation.messages | 
| 118 | 
             
            								{@const sliced = msgs.slice(0, 4)}
         | 
| 119 | 
             
            								<div
         | 
| 120 | 
             
            									class={[
         | 
| @@ -131,7 +133,11 @@ | |
| 131 | 
             
            										{@const isLast = i === sliced.length - 1}
         | 
| 132 | 
             
            										<div class="flex flex-col gap-1 p-2">
         | 
| 133 | 
             
            											<p class="font-mono text-xs font-medium text-gray-400 uppercase">{msg.role}</p>
         | 
| 134 | 
            -
            											 | 
|  | |
|  | |
|  | |
|  | |
| 135 | 
             
            										</div>
         | 
| 136 | 
             
            										{#if !isLast}
         | 
| 137 | 
             
            											<div class="my-2 h-px w-full bg-gray-200 dark:bg-gray-700"></div>
         | 
|  | |
| 72 | 
             
            									<IconCompare class="text-xs text-gray-400" />
         | 
| 73 | 
             
            								{/if}
         | 
| 74 | 
             
            								{#each state.conversations as { messages }, i}
         | 
| 75 | 
            +
            									<span class={["text-gray-800 dark:text-gray-200"]}>
         | 
| 76 | 
            +
            										{messages.length} message{messages.length === 1 ? "" : "s"}
         | 
| 77 | 
             
            									</span>
         | 
| 78 | 
             
            									{#if multiple && i === 0}
         | 
| 79 | 
             
            										<span class="text-gray-500">|</span>
         | 
|  | |
| 108 |  | 
| 109 | 
             
            					{#if tooltip.open}
         | 
| 110 | 
             
            						<div
         | 
| 111 | 
            +
            							class={[
         | 
| 112 | 
            +
            								"flex rounded-xl border border-gray-100 bg-gray-50 p-2 shadow dark:border-gray-700 dark:bg-gray-800",
         | 
| 113 | 
            +
            							]}
         | 
| 114 | 
             
            							{...tooltip.content}
         | 
| 115 | 
             
            							transition:fly={{ x: -2 }}
         | 
| 116 | 
             
            						>
         | 
| 117 | 
             
            							<div class="size-4 rounded-tl border-t border-l border-gray-700" {...tooltip.arrow}></div>
         | 
| 118 | 
             
            							{#each state.conversations as conversation, i}
         | 
| 119 | 
            +
            								{@const msgs = conversation.messages}
         | 
| 120 | 
             
            								{@const sliced = msgs.slice(0, 4)}
         | 
| 121 | 
             
            								<div
         | 
| 122 | 
             
            									class={[
         | 
|  | |
| 133 | 
             
            										{@const isLast = i === sliced.length - 1}
         | 
| 134 | 
             
            										<div class="flex flex-col gap-1 p-2">
         | 
| 135 | 
             
            											<p class="font-mono text-xs font-medium text-gray-400 uppercase">{msg.role}</p>
         | 
| 136 | 
            +
            											{#if msg.content?.trim()}
         | 
| 137 | 
            +
            												<p class="line-clamp-2 text-sm">{msg.content.trim()}</p>
         | 
| 138 | 
            +
            											{:else}
         | 
| 139 | 
            +
            												<p class="text-sm text-gray-500 italic">No content</p>
         | 
| 140 | 
            +
            											{/if}
         | 
| 141 | 
             
            										</div>
         | 
| 142 | 
             
            										{#if !isLast}
         | 
| 143 | 
             
            											<div class="my-2 h-px w-full bg-gray-200 dark:bg-gray-700"></div>
         | 
    	
        src/lib/components/inference-playground/code-snippets.svelte
    CHANGED
    
    | @@ -101,7 +101,6 @@ | |
| 101 | 
             
            	};
         | 
| 102 |  | 
| 103 | 
             
            	function highlight(code?: string, language?: InferenceSnippetLanguage) {
         | 
| 104 | 
            -
            		console.log({ code, language });
         | 
| 105 | 
             
            		if (!code || !language) return "";
         | 
| 106 | 
             
            		return hljs.highlight(code, { language: language === "curl" ? "http" : language }).value;
         | 
| 107 | 
             
            	}
         | 
| @@ -196,8 +195,8 @@ | |
| 196 | 
             
            	{/if}
         | 
| 197 |  | 
| 198 | 
             
            	{#if installInstructions}
         | 
| 199 | 
            -
            		<div class="flex  | 
| 200 | 
            -
            			<h2 class="flex items- | 
| 201 | 
             
            				{installInstructions.title}
         | 
| 202 | 
             
            				<a
         | 
| 203 | 
             
            					href={installInstructions.docs}
         | 
| @@ -208,7 +207,7 @@ | |
| 208 | 
             
            					Docs
         | 
| 209 | 
             
            				</a>
         | 
| 210 | 
             
            			</h2>
         | 
| 211 | 
            -
            			<div class="flex items-center gap-x-4">
         | 
| 212 | 
             
            				<LocalToasts>
         | 
| 213 | 
             
            					{#snippet children({ addToast, trigger })}
         | 
| 214 | 
             
            						<button
         | 
|  | |
| 101 | 
             
            	};
         | 
| 102 |  | 
| 103 | 
             
            	function highlight(code?: string, language?: InferenceSnippetLanguage) {
         | 
|  | |
| 104 | 
             
            		if (!code || !language) return "";
         | 
| 105 | 
             
            		return hljs.highlight(code, { language: language === "curl" ? "http" : language }).value;
         | 
| 106 | 
             
            	}
         | 
|  | |
| 195 | 
             
            	{/if}
         | 
| 196 |  | 
| 197 | 
             
            	{#if installInstructions}
         | 
| 198 | 
            +
            		<div class="flex flex-col justify-between gap-2 px-2 pt-6 pb-4 md:flex-row md:items-center">
         | 
| 199 | 
            +
            			<h2 class="flex items-center gap-2 font-semibold">
         | 
| 200 | 
             
            				{installInstructions.title}
         | 
| 201 | 
             
            				<a
         | 
| 202 | 
             
            					href={installInstructions.docs}
         | 
|  | |
| 207 | 
             
            					Docs
         | 
| 208 | 
             
            				</a>
         | 
| 209 | 
             
            			</h2>
         | 
| 210 | 
            +
            			<div class="flex items-center gap-x-4 whitespace-nowrap">
         | 
| 211 | 
             
            				<LocalToasts>
         | 
| 212 | 
             
            					{#snippet children({ addToast, trigger })}
         | 
| 213 | 
             
            						<button
         | 
    	
        src/lib/components/inference-playground/conversation.svelte
    CHANGED
    
    | @@ -12,10 +12,9 @@ | |
| 12 | 
             
            		conversation: Conversation;
         | 
| 13 | 
             
            		loading: boolean;
         | 
| 14 | 
             
            		viewCode: boolean;
         | 
| 15 | 
            -
            		compareActive: boolean;
         | 
| 16 | 
             
            	}
         | 
| 17 |  | 
| 18 | 
            -
            	let { conversation = $bindable(), loading, viewCode | 
| 19 | 
             
            	let messageContainer: HTMLDivElement | null = $state(null);
         | 
| 20 | 
             
            	const scrollState = new ScrollState({
         | 
| 21 | 
             
            		element: () => messageContainer,
         | 
| @@ -56,9 +55,7 @@ | |
| 56 | 
             
            </script>
         | 
| 57 |  | 
| 58 | 
             
            <div
         | 
| 59 | 
            -
            	class="@container flex flex-col overflow-x-hidden overflow-y-auto | 
| 60 | 
            -
            		? 'max-h-[calc(100dvh-5.8rem-2.5rem-75px)] md:max-h-[calc(100dvh-5.8rem-2.5rem)]'
         | 
| 61 | 
            -
            		: 'max-h-[calc(100dvh-5.8rem-2.5rem-75px)] md:max-h-[calc(100dvh-5.8rem)]'}"
         | 
| 62 | 
             
            	class:animate-pulse={loading && !conversation.streaming}
         | 
| 63 | 
             
            	bind:this={messageContainer}
         | 
| 64 | 
             
            	id="test-this"
         | 
|  | |
| 12 | 
             
            		conversation: Conversation;
         | 
| 13 | 
             
            		loading: boolean;
         | 
| 14 | 
             
            		viewCode: boolean;
         | 
|  | |
| 15 | 
             
            	}
         | 
| 16 |  | 
| 17 | 
            +
            	let { conversation = $bindable(), loading, viewCode }: Props = $props();
         | 
| 18 | 
             
            	let messageContainer: HTMLDivElement | null = $state(null);
         | 
| 19 | 
             
            	const scrollState = new ScrollState({
         | 
| 20 | 
             
            		element: () => messageContainer,
         | 
|  | |
| 55 | 
             
            </script>
         | 
| 56 |  | 
| 57 | 
             
            <div
         | 
| 58 | 
            +
            	class="@container flex flex-col overflow-x-hidden overflow-y-auto"
         | 
|  | |
|  | |
| 59 | 
             
            	class:animate-pulse={loading && !conversation.streaming}
         | 
| 60 | 
             
            	bind:this={messageContainer}
         | 
| 61 | 
             
            	id="test-this"
         | 
    	
        src/lib/components/inference-playground/custom-model-config.svelte
    CHANGED
    
    | @@ -30,6 +30,10 @@ | |
| 30 | 
             
            	import IconCross from "~icons/carbon/close";
         | 
| 31 | 
             
            	import typia from "typia";
         | 
| 32 | 
             
            	import { handleNonStreamingResponse } from "./utils.js";
         | 
|  | |
|  | |
|  | |
|  | |
| 33 |  | 
| 34 | 
             
            	let dialog: HTMLDialogElement | undefined = $state();
         | 
| 35 | 
             
            	const exists = $derived(!!models.custom.find(m => m._id === model?._id));
         | 
| @@ -55,12 +59,24 @@ | |
| 55 | 
             
            	const success = (content: string) => (message = { type: "success", content }) satisfies Message;
         | 
| 56 | 
             
            	const clear = () => (message = null);
         | 
| 57 |  | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 58 | 
             
            	const onsubmit: HTMLFormAttributes["onsubmit"] = async e => {
         | 
| 59 | 
             
            		e.preventDefault();
         | 
| 60 | 
             
            		clear();
         | 
| 61 | 
             
            		const isTest = e.submitter?.dataset.form === "test";
         | 
| 62 | 
             
            		if (isTest) {
         | 
| 63 | 
             
            			testing = true;
         | 
|  | |
| 64 |  | 
| 65 | 
             
            			const conv: Conversation = {
         | 
| 66 | 
             
            				model: {
         | 
| @@ -83,6 +99,7 @@ | |
| 83 | 
             
            			try {
         | 
| 84 | 
             
            				await handleNonStreamingResponse(conv);
         | 
| 85 | 
             
            				success("Test successful!");
         | 
|  | |
| 86 | 
             
            			} catch (err) {
         | 
| 87 | 
             
            				if (err instanceof Error) {
         | 
| 88 | 
             
            					error(`Test failed: ${err.message}`);
         | 
| @@ -101,7 +118,13 @@ | |
| 101 | 
             
            		}
         | 
| 102 | 
             
            	};
         | 
| 103 |  | 
| 104 | 
            -
            	 | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 105 | 
             
            </script>
         | 
| 106 |  | 
| 107 | 
             
            <dialog class="backdrop:bg-transparent" bind:this={dialog} onclose={() => close()}>
         | 
| @@ -158,7 +181,9 @@ | |
| 158 | 
             
            							required
         | 
| 159 | 
             
            							type="text"
         | 
| 160 | 
             
            							class="input block w-full"
         | 
|  | |
| 161 | 
             
            						/>
         | 
|  | |
| 162 | 
             
            					</label>
         | 
| 163 | 
             
            					<label class="flex flex-col gap-2">
         | 
| 164 | 
             
            						<p class="block text-sm font-medium text-gray-900 dark:text-white">Access Token</p>
         | 
| @@ -204,17 +229,28 @@ | |
| 204 | 
             
            					{/if}
         | 
| 205 | 
             
            					<!-- Reverse flex so that submit is the button called on enter -->
         | 
| 206 | 
             
            					<div class="ml-auto flex flex-row-reverse items-center gap-2">
         | 
| 207 | 
            -
            						< | 
| 208 | 
            -
            							 | 
| 209 | 
            -
             | 
| 210 | 
            -
             | 
| 211 | 
            -
            									 | 
| 212 | 
            -
            									 | 
| 213 | 
            -
             | 
| 214 | 
            -
             | 
| 215 | 
            -
             | 
| 216 | 
            -
             | 
| 217 | 
            -
             | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 218 | 
             
            						<button
         | 
| 219 | 
             
            							data-form="test"
         | 
| 220 | 
             
            							type="submit"
         | 
|  | |
| 30 | 
             
            	import IconCross from "~icons/carbon/close";
         | 
| 31 | 
             
            	import typia from "typia";
         | 
| 32 | 
             
            	import { handleNonStreamingResponse } from "./utils.js";
         | 
| 33 | 
            +
            	import { watch } from "runed";
         | 
| 34 | 
            +
            	import Tooltip from "../tooltip.svelte";
         | 
| 35 | 
            +
            	import { createFieldValidation } from "$lib/utils/form.svelte.js";
         | 
| 36 | 
            +
            	import { isValidURL } from "$lib/utils/url.js";
         | 
| 37 |  | 
| 38 | 
             
            	let dialog: HTMLDialogElement | undefined = $state();
         | 
| 39 | 
             
            	const exists = $derived(!!models.custom.find(m => m._id === model?._id));
         | 
|  | |
| 59 | 
             
            	const success = (content: string) => (message = { type: "success", content }) satisfies Message;
         | 
| 60 | 
             
            	const clear = () => (message = null);
         | 
| 61 |  | 
| 62 | 
            +
            	watch(
         | 
| 63 | 
            +
            		() => $state.snapshot(model),
         | 
| 64 | 
            +
            		(_, prev) => {
         | 
| 65 | 
            +
            			if (prev === undefined) testSuccessful = exists;
         | 
| 66 | 
            +
            			else testSuccessful = false;
         | 
| 67 | 
            +
            		},
         | 
| 68 | 
            +
            		{ lazy: true }
         | 
| 69 | 
            +
            	);
         | 
| 70 | 
            +
             | 
| 71 | 
            +
            	let testing = $state(false);
         | 
| 72 | 
            +
            	let testSuccessful = $state(false);
         | 
| 73 | 
             
            	const onsubmit: HTMLFormAttributes["onsubmit"] = async e => {
         | 
| 74 | 
             
            		e.preventDefault();
         | 
| 75 | 
             
            		clear();
         | 
| 76 | 
             
            		const isTest = e.submitter?.dataset.form === "test";
         | 
| 77 | 
             
            		if (isTest) {
         | 
| 78 | 
             
            			testing = true;
         | 
| 79 | 
            +
            			testSuccessful = false;
         | 
| 80 |  | 
| 81 | 
             
            			const conv: Conversation = {
         | 
| 82 | 
             
            				model: {
         | 
|  | |
| 99 | 
             
            			try {
         | 
| 100 | 
             
            				await handleNonStreamingResponse(conv);
         | 
| 101 | 
             
            				success("Test successful!");
         | 
| 102 | 
            +
            				testSuccessful = true;
         | 
| 103 | 
             
            			} catch (err) {
         | 
| 104 | 
             
            				if (err instanceof Error) {
         | 
| 105 | 
             
            					error(`Test failed: ${err.message}`);
         | 
|  | |
| 118 | 
             
            		}
         | 
| 119 | 
             
            	};
         | 
| 120 |  | 
| 121 | 
            +
            	const endpointValidation = createFieldValidation({
         | 
| 122 | 
            +
            		validate: v => {
         | 
| 123 | 
            +
            			if (!v) return "Endpoint URL is required";
         | 
| 124 | 
            +
            			if (!isValidURL(v)) return "Invalid URL";
         | 
| 125 | 
            +
            			if (!v.endsWith("/v1")) return "Endpoint URL should *probably* end with /v1";
         | 
| 126 | 
            +
            		},
         | 
| 127 | 
            +
            	});
         | 
| 128 | 
             
            </script>
         | 
| 129 |  | 
| 130 | 
             
            <dialog class="backdrop:bg-transparent" bind:this={dialog} onclose={() => close()}>
         | 
|  | |
| 181 | 
             
            							required
         | 
| 182 | 
             
            							type="text"
         | 
| 183 | 
             
            							class="input block w-full"
         | 
| 184 | 
            +
            							{...endpointValidation.attrs}
         | 
| 185 | 
             
            						/>
         | 
| 186 | 
            +
            						<p class="text-xs text-red-300">{endpointValidation.msg}</p>
         | 
| 187 | 
             
            					</label>
         | 
| 188 | 
             
            					<label class="flex flex-col gap-2">
         | 
| 189 | 
             
            						<p class="block text-sm font-medium text-gray-900 dark:text-white">Access Token</p>
         | 
|  | |
| 229 | 
             
            					{/if}
         | 
| 230 | 
             
            					<!-- Reverse flex so that submit is the button called on enter -->
         | 
| 231 | 
             
            					<div class="ml-auto flex flex-row-reverse items-center gap-2">
         | 
| 232 | 
            +
            						<Tooltip disabled={testSuccessful} openDelay={0} closeOnPointerDown={false}>
         | 
| 233 | 
            +
            							{#snippet trigger(tooltip)}
         | 
| 234 | 
            +
            								<button
         | 
| 235 | 
            +
            									data-form="submit"
         | 
| 236 | 
            +
            									type="submit"
         | 
| 237 | 
            +
            									class={[
         | 
| 238 | 
            +
            										"rounded-lg bg-black px-5 py-2.5 text-sm",
         | 
| 239 | 
            +
            										"font-medium text-white",
         | 
| 240 | 
            +
            										"hover:nd:bg-gray-900 focus:ring-4 focus:ring-gray-300",
         | 
| 241 | 
            +
            										"focus:outline-none disabled:cursor-not-allowed disabled:opacity-75",
         | 
| 242 | 
            +
            										"dark:hover:nd:bg-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:focus:ring-gray-700",
         | 
| 243 | 
            +
            									]}
         | 
| 244 | 
            +
            									disabled={testing || !testSuccessful}
         | 
| 245 | 
            +
            									{...tooltip.trigger}
         | 
| 246 | 
            +
            								>
         | 
| 247 | 
            +
            									Submit
         | 
| 248 | 
            +
            								</button>
         | 
| 249 | 
            +
            							{/snippet}
         | 
| 250 | 
            +
            							{#if !testSuccessful}
         | 
| 251 | 
            +
            								<p>Test your model before saving</p>
         | 
| 252 | 
            +
            							{/if}
         | 
| 253 | 
            +
            						</Tooltip>
         | 
| 254 | 
             
            						<button
         | 
| 255 | 
             
            							data-form="test"
         | 
| 256 | 
             
            							type="submit"
         | 
    	
        src/lib/components/inference-playground/message.svelte
    CHANGED
    
    | @@ -1,4 +1,5 @@ | |
| 1 | 
             
            <script lang="ts">
         | 
|  | |
| 2 | 
             
            	import Tooltip from "$lib/components/tooltip.svelte";
         | 
| 3 | 
             
            	import { TextareaAutosize } from "$lib/spells/textarea-autosize.svelte.js";
         | 
| 4 | 
             
            	import { PipelineTag, type Conversation, type ConversationMessage } from "$lib/types.js";
         | 
| @@ -71,16 +72,14 @@ | |
| 71 | 
             
            			{message.role}
         | 
| 72 | 
             
            		</div>
         | 
| 73 | 
             
            		<div class="flex w-full gap-4">
         | 
| 74 | 
            -
            			<!-- svelte-ignore a11y_autofocus -->
         | 
| 75 | 
            -
            			<!-- svelte-ignore a11y_positive_tabindex -->
         | 
| 76 | 
             
            			<textarea
         | 
| 77 | 
             
            				bind:this={element}
         | 
| 78 | 
            -
            				{autofocus}
         | 
| 79 | 
             
            				bind:value={message.content}
         | 
| 80 | 
             
            				placeholder="Enter {message.role} message"
         | 
| 81 | 
             
            				class="grow resize-none overflow-hidden rounded-lg bg-transparent px-2 py-2.5 ring-gray-100 outline-none group-hover/message:ring-3 hover:bg-white focus:bg-white focus:ring-3 @2xl:px-3 dark:ring-gray-600 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
         | 
| 82 | 
             
            				rows="1"
         | 
| 83 | 
            -
            				 | 
| 84 | 
             
            			></textarea>
         | 
| 85 |  | 
| 86 | 
             
            			{#if canUploadImgs}
         | 
|  | |
| 1 | 
             
            <script lang="ts">
         | 
| 2 | 
            +
            	import { autofocus as autofocusAction } from "$lib/actions/autofocus.js";
         | 
| 3 | 
             
            	import Tooltip from "$lib/components/tooltip.svelte";
         | 
| 4 | 
             
            	import { TextareaAutosize } from "$lib/spells/textarea-autosize.svelte.js";
         | 
| 5 | 
             
            	import { PipelineTag, type Conversation, type ConversationMessage } from "$lib/types.js";
         | 
|  | |
| 72 | 
             
            			{message.role}
         | 
| 73 | 
             
            		</div>
         | 
| 74 | 
             
            		<div class="flex w-full gap-4">
         | 
|  | |
|  | |
| 75 | 
             
            			<textarea
         | 
| 76 | 
             
            				bind:this={element}
         | 
| 77 | 
            +
            				use:autofocusAction={autofocus}
         | 
| 78 | 
             
            				bind:value={message.content}
         | 
| 79 | 
             
            				placeholder="Enter {message.role} message"
         | 
| 80 | 
             
            				class="grow resize-none overflow-hidden rounded-lg bg-transparent px-2 py-2.5 ring-gray-100 outline-none group-hover/message:ring-3 hover:bg-white focus:bg-white focus:ring-3 @2xl:px-3 dark:ring-gray-600 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
         | 
| 81 | 
             
            				rows="1"
         | 
| 82 | 
            +
            				data-message
         | 
| 83 | 
             
            			></textarea>
         | 
| 84 |  | 
| 85 | 
             
            			{#if canUploadImgs}
         | 
    	
        src/lib/components/inference-playground/model-selector-modal.svelte
    CHANGED
    
    | @@ -70,137 +70,135 @@ | |
| 70 |  | 
| 71 | 
             
            <!-- svelte-ignore a11y_no_static_element_interactions -->
         | 
| 72 | 
             
            <!-- svelte-ignore a11y_click_events_have_key_events -->
         | 
| 73 | 
            -
            <div
         | 
| 74 | 
            -
            	 | 
| 75 | 
            -
             | 
| 76 | 
            -
            	 | 
| 77 | 
            -
            >
         | 
| 78 | 
            -
             | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 79 | 
             
            		<div
         | 
| 80 | 
            -
            			class=" | 
|  | |
|  | |
| 81 | 
             
            		>
         | 
| 82 | 
            -
            			 | 
| 83 | 
            -
            				 | 
| 84 | 
            -
             | 
| 85 | 
            -
             | 
| 86 | 
            -
            				<input
         | 
| 87 | 
            -
            					{...combobox.input}
         | 
| 88 | 
            -
            					use:autofocus
         | 
| 89 | 
            -
            					class="flex h-10 w-full rounded-md bg-transparent py-3 text-sm placeholder-gray-400 outline-hidden"
         | 
| 90 | 
            -
            					placeholder="Search models ..."
         | 
| 91 | 
            -
            					bind:value={query}
         | 
| 92 | 
            -
            				/>
         | 
| 93 | 
            -
            			</div>
         | 
| 94 | 
            -
            			<div class="max-h-[300px] overflow-x-hidden overflow-y-auto" {...combobox.content} popover={undefined}>
         | 
| 95 | 
            -
            				{#snippet modelEntry(model: Model | CustomModel, trending?: boolean)}
         | 
| 96 | 
            -
            					{@const [nameSpace, modelName] = model.id.split("/")}
         | 
| 97 | 
            -
            					<button
         | 
| 98 | 
            -
            						class="flex w-full cursor-pointer items-center px-2 py-1.5 text-sm
         | 
| 99 | 
             
            						data-[highlighted]:bg-gray-100 data-[highlighted]:dark:bg-gray-800"
         | 
| 100 | 
            -
             | 
| 101 | 
            -
             | 
| 102 | 
            -
             | 
| 103 | 
            -
             | 
| 104 | 
            -
             | 
| 105 | 
            -
             | 
| 106 | 
            -
             | 
| 107 | 
            -
             | 
| 108 |  | 
| 109 | 
            -
             | 
| 110 | 
            -
             | 
| 111 | 
            -
             | 
| 112 | 
            -
             | 
| 113 | 
            -
             | 
| 114 | 
            -
             | 
| 115 | 
            -
             | 
| 116 | 
            -
             | 
| 117 | 
            -
             | 
| 118 |  | 
| 119 | 
            -
             | 
| 120 | 
            -
             | 
| 121 | 
            -
             | 
| 122 | 
            -
             | 
| 123 | 
            -
             | 
| 124 | 
            -
             | 
| 125 | 
            -
             | 
| 126 | 
            -
             | 
| 127 | 
            -
             | 
| 128 | 
            -
             | 
| 129 | 
            -
             | 
| 130 | 
            -
             | 
| 131 | 
            -
             | 
| 132 |  | 
| 133 | 
            -
             | 
| 134 | 
            -
             | 
| 135 | 
            -
             | 
| 136 | 
            -
             | 
| 137 | 
            -
             | 
| 138 | 
            -
             | 
| 139 | 
            -
             | 
| 140 | 
            -
             | 
| 141 | 
            -
             | 
| 142 | 
            -
             | 
| 143 | 
            -
             | 
| 144 | 
            -
             | 
| 145 | 
            -
             | 
| 146 | 
            -
             | 
| 147 | 
            -
             | 
| 148 | 
            -
             | 
| 149 | 
             
            					hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500"
         | 
| 150 | 
            -
             | 
| 151 | 
            -
             | 
| 152 | 
            -
             | 
| 153 | 
            -
             | 
| 154 | 
            -
             | 
| 155 | 
            -
             | 
| 156 | 
            -
             | 
| 157 | 
            -
             | 
| 158 | 
            -
             | 
| 159 | 
            -
             | 
| 160 | 
            -
             | 
| 161 | 
            -
             | 
| 162 | 
            -
             | 
| 163 | 
            -
             | 
| 164 | 
            -
             | 
| 165 | 
            -
             | 
| 166 | 
            -
             | 
| 167 | 
            -
             | 
| 168 | 
            -
             | 
| 169 | 
            -
            					</button>
         | 
| 170 | 
            -
            				{/snippet}
         | 
| 171 | 
            -
            				{#if trending.length > 0}
         | 
| 172 | 
            -
            					<div class="px-2 py-1.5 text-xs font-medium text-gray-500">Trending</div>
         | 
| 173 | 
            -
            					{#each trending as model}
         | 
| 174 | 
            -
            						{@render modelEntry(model, true)}
         | 
| 175 | 
            -
            					{/each}
         | 
| 176 | 
            -
            				{/if}
         | 
| 177 | 
            -
            				<div class="px-2 py-1.5 text-xs font-medium text-gray-500">Custom endpoints</div>
         | 
| 178 | 
            -
            				{#if custom.length > 0}
         | 
| 179 | 
            -
            					{#each custom as model}
         | 
| 180 | 
            -
            						{@render modelEntry(model, false)}
         | 
| 181 | 
            -
            					{/each}
         | 
| 182 | 
            -
            				{/if}
         | 
| 183 | 
            -
            				<button
         | 
| 184 | 
            -
            					class="flex w-full cursor-pointer items-center gap-2 px-2 py-1.5 text-sm text-gray-500 data-[highlighted]:bg-blue-500/15 data-[highlighted]:text-blue-600 dark:text-gray-400 dark:data-[highlighted]:text-blue-300"
         | 
| 185 | 
            -
            					{...combobox.getOption("__custom__", () => {
         | 
| 186 | 
            -
            						onClose?.();
         | 
| 187 | 
            -
            						openCustomModelConfig({
         | 
| 188 | 
            -
            							onSubmit: model => {
         | 
| 189 | 
            -
            								onModelSelect?.(model.id);
         | 
| 190 | 
            -
            							},
         | 
| 191 | 
            -
            						});
         | 
| 192 | 
            -
            					})}
         | 
| 193 | 
            -
            				>
         | 
| 194 | 
            -
            					<IconAdd class="rounded bg-blue-500/10 text-blue-600" />
         | 
| 195 | 
            -
            					Add a custom endpoint
         | 
| 196 | 
             
            				</button>
         | 
| 197 | 
            -
             | 
| 198 | 
            -
             | 
| 199 | 
            -
             | 
| 200 | 
            -
             | 
| 201 | 
            -
            					{ | 
| 202 | 
            -
            				{/ | 
| 203 | 
            -
            			 | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 204 | 
             
            		</div>
         | 
| 205 | 
             
            	</div>
         | 
| 206 | 
             
            </div>
         | 
|  | |
| 70 |  | 
| 71 | 
             
            <!-- svelte-ignore a11y_no_static_element_interactions -->
         | 
| 72 | 
             
            <!-- svelte-ignore a11y_click_events_have_key_events -->
         | 
| 73 | 
            +
            <div class="fixed inset-0 z-10 h-dvh bg-black/85 pt-32" bind:this={backdropEl} onclick={handleBackdropClick}>
         | 
| 74 | 
            +
            	<div
         | 
| 75 | 
            +
            		class="abs-x-center md:abs-y-center absolute top-12 flex w-[calc(100%-2rem)] max-w-[600px] flex-col overflow-hidden rounded-lg border bg-white text-gray-900 shadow-md dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300"
         | 
| 76 | 
            +
            	>
         | 
| 77 | 
            +
            		<div class="flex items-center border-b px-3 dark:border-gray-800">
         | 
| 78 | 
            +
            			<div class="mr-2 text-sm">
         | 
| 79 | 
            +
            				<IconSearch />
         | 
| 80 | 
            +
            			</div>
         | 
| 81 | 
            +
            			<input
         | 
| 82 | 
            +
            				{...combobox.input}
         | 
| 83 | 
            +
            				use:autofocus
         | 
| 84 | 
            +
            				class="flex h-10 w-full rounded-md bg-transparent py-3 text-sm placeholder-gray-400 outline-hidden"
         | 
| 85 | 
            +
            				placeholder="Search models ..."
         | 
| 86 | 
            +
            				bind:value={query}
         | 
| 87 | 
            +
            			/>
         | 
| 88 | 
            +
            		</div>
         | 
| 89 | 
             
            		<div
         | 
| 90 | 
            +
            			class="max-h-[220px] overflow-x-hidden overflow-y-auto md:max-h-[300px]"
         | 
| 91 | 
            +
            			{...combobox.content}
         | 
| 92 | 
            +
            			popover={undefined}
         | 
| 93 | 
             
            		>
         | 
| 94 | 
            +
            			{#snippet modelEntry(model: Model | CustomModel, trending?: boolean)}
         | 
| 95 | 
            +
            				{@const [nameSpace, modelName] = model.id.split("/")}
         | 
| 96 | 
            +
            				<button
         | 
| 97 | 
            +
            					class="flex w-full cursor-pointer items-center px-2 py-1.5 text-sm
         | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 98 | 
             
            						data-[highlighted]:bg-gray-100 data-[highlighted]:dark:bg-gray-800"
         | 
| 99 | 
            +
            					data-model
         | 
| 100 | 
            +
            					{...combobox.getOption(model.id)}
         | 
| 101 | 
            +
            				>
         | 
| 102 | 
            +
            					{#if trending}
         | 
| 103 | 
            +
            						<div class=" mr-1.5 size-4 text-yellow-400">
         | 
| 104 | 
            +
            							<IconStar />
         | 
| 105 | 
            +
            						</div>
         | 
| 106 | 
            +
            					{/if}
         | 
| 107 |  | 
| 108 | 
            +
            					{#if modelName}
         | 
| 109 | 
            +
            						<span class="inline-flex items-center">
         | 
| 110 | 
            +
            							<span class="text-gray-500 dark:text-gray-400">{nameSpace}</span>
         | 
| 111 | 
            +
            							<span class="mx-1 text-gray-300 dark:text-gray-700">/</span>
         | 
| 112 | 
            +
            							<span class="text-black dark:text-white">{modelName}</span>
         | 
| 113 | 
            +
            						</span>
         | 
| 114 | 
            +
            					{:else}
         | 
| 115 | 
            +
            						<span class="text-black dark:text-white">{nameSpace}</span>
         | 
| 116 | 
            +
            					{/if}
         | 
| 117 |  | 
| 118 | 
            +
            					{#if "pipeline_tag" in model && model.pipeline_tag === "image-text-to-text"}
         | 
| 119 | 
            +
            						<Tooltip openDelay={100}>
         | 
| 120 | 
            +
            							{#snippet trigger(tooltip)}
         | 
| 121 | 
            +
            								<div
         | 
| 122 | 
            +
            									class="ml-2 grid size-5 place-items-center rounded bg-gray-500/10 text-gray-500 dark:bg-gray-500/20 dark:text-gray-300"
         | 
| 123 | 
            +
            									{...tooltip.trigger}
         | 
| 124 | 
            +
            								>
         | 
| 125 | 
            +
            									<IconEye class="size-3.5" />
         | 
| 126 | 
            +
            								</div>
         | 
| 127 | 
            +
            							{/snippet}
         | 
| 128 | 
            +
            							Image text-to-text
         | 
| 129 | 
            +
            						</Tooltip>
         | 
| 130 | 
            +
            					{/if}
         | 
| 131 |  | 
| 132 | 
            +
            					{#if isCustom(model)}
         | 
| 133 | 
            +
            						<Tooltip openDelay={100}>
         | 
| 134 | 
            +
            							{#snippet trigger(tooltip)}
         | 
| 135 | 
            +
            								<div
         | 
| 136 | 
            +
            									class="ml-2 grid size-5 place-items-center rounded bg-gray-500/10 text-gray-500 dark:bg-gray-500/20 dark:text-gray-300"
         | 
| 137 | 
            +
            									{...tooltip.trigger}
         | 
| 138 | 
            +
            								>
         | 
| 139 | 
            +
            									<IconCube class="size-3.5" />
         | 
| 140 | 
            +
            								</div>
         | 
| 141 | 
            +
            							{/snippet}
         | 
| 142 | 
            +
            							Custom Model
         | 
| 143 | 
            +
            						</Tooltip>
         | 
| 144 | 
            +
            						<Tooltip>
         | 
| 145 | 
            +
            							{#snippet trigger(tooltip)}
         | 
| 146 | 
            +
            								<button
         | 
| 147 | 
            +
            									class="mr-1 ml-auto grid size-4.5 place-items-center rounded-sm bg-gray-100 text-xs
         | 
| 148 | 
             
            					hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500"
         | 
| 149 | 
            +
            									aria-label="Add custom model"
         | 
| 150 | 
            +
            									{...tooltip.trigger}
         | 
| 151 | 
            +
            									onclick={e => {
         | 
| 152 | 
            +
            										e.stopPropagation();
         | 
| 153 | 
            +
            										onClose?.();
         | 
| 154 | 
            +
            										openCustomModelConfig({
         | 
| 155 | 
            +
            											model,
         | 
| 156 | 
            +
            											onSubmit: model => {
         | 
| 157 | 
            +
            												onModelSelect?.(model.id);
         | 
| 158 | 
            +
            											},
         | 
| 159 | 
            +
            										});
         | 
| 160 | 
            +
            									}}
         | 
| 161 | 
            +
            								>
         | 
| 162 | 
            +
            									<IconEdit class="size-3" />
         | 
| 163 | 
            +
            								</button>
         | 
| 164 | 
            +
            							{/snippet}
         | 
| 165 | 
            +
            							<span class="text-sm">Edit</span>
         | 
| 166 | 
            +
            						</Tooltip>
         | 
| 167 | 
            +
            					{/if}
         | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 168 | 
             
            				</button>
         | 
| 169 | 
            +
            			{/snippet}
         | 
| 170 | 
            +
            			{#if trending.length > 0}
         | 
| 171 | 
            +
            				<div class="px-2 py-1.5 text-xs font-medium text-gray-500">Trending</div>
         | 
| 172 | 
            +
            				{#each trending as model}
         | 
| 173 | 
            +
            					{@render modelEntry(model, true)}
         | 
| 174 | 
            +
            				{/each}
         | 
| 175 | 
            +
            			{/if}
         | 
| 176 | 
            +
            			<div class="px-2 py-1.5 text-xs font-medium text-gray-500">Custom endpoints</div>
         | 
| 177 | 
            +
            			{#if custom.length > 0}
         | 
| 178 | 
            +
            				{#each custom as model}
         | 
| 179 | 
            +
            					{@render modelEntry(model, false)}
         | 
| 180 | 
            +
            				{/each}
         | 
| 181 | 
            +
            			{/if}
         | 
| 182 | 
            +
            			<button
         | 
| 183 | 
            +
            				class="flex w-full cursor-pointer items-center gap-2 px-2 py-1.5 text-sm text-gray-500 data-[highlighted]:bg-blue-500/15 data-[highlighted]:text-blue-600 dark:text-gray-400 dark:data-[highlighted]:text-blue-300"
         | 
| 184 | 
            +
            				{...combobox.getOption("__custom__", () => {
         | 
| 185 | 
            +
            					onClose?.();
         | 
| 186 | 
            +
            					openCustomModelConfig({
         | 
| 187 | 
            +
            						onSubmit: model => {
         | 
| 188 | 
            +
            							onModelSelect?.(model.id);
         | 
| 189 | 
            +
            						},
         | 
| 190 | 
            +
            					});
         | 
| 191 | 
            +
            				})}
         | 
| 192 | 
            +
            			>
         | 
| 193 | 
            +
            				<IconAdd class="rounded bg-blue-500/10 text-blue-600" />
         | 
| 194 | 
            +
            				Add a custom endpoint
         | 
| 195 | 
            +
            			</button>
         | 
| 196 | 
            +
            			{#if other.length > 0}
         | 
| 197 | 
            +
            				<div class="px-2 py-1.5 text-xs font-medium text-gray-500">Other models</div>
         | 
| 198 | 
            +
            				{#each other as model}
         | 
| 199 | 
            +
            					{@render modelEntry(model, false)}
         | 
| 200 | 
            +
            				{/each}
         | 
| 201 | 
            +
            			{/if}
         | 
| 202 | 
             
            		</div>
         | 
| 203 | 
             
            	</div>
         | 
| 204 | 
             
            </div>
         | 
    	
        src/lib/components/inference-playground/playground.svelte
    CHANGED
    
    | @@ -177,7 +177,8 @@ | |
| 177 | 
             
            		const RE_HF_TOKEN = /\bhf_[a-zA-Z0-9]{34}\b/;
         | 
| 178 | 
             
            		if (RE_HF_TOKEN.test(submittedHfToken)) {
         | 
| 179 | 
             
            			token.value = submittedHfToken;
         | 
| 180 | 
            -
            			submit | 
|  | |
| 181 | 
             
            		} else {
         | 
| 182 | 
             
            			alert("Please provide a valid HF token.");
         | 
| 183 | 
             
            		}
         | 
| @@ -211,13 +212,18 @@ | |
| 211 |  | 
| 212 | 
             
            <!-- svelte-ignore a11y_no_static_element_interactions -->
         | 
| 213 | 
             
            <div
         | 
| 214 | 
            -
            	class= | 
| 215 | 
            -
            		 | 
| 216 | 
            -
            		 | 
|  | |
|  | |
|  | |
|  | |
|  | |
| 217 | 
             
            >
         | 
| 218 | 
             
            	<!-- First column -->
         | 
| 219 | 
             
            	<div class="flex flex-col gap-2 overflow-y-auto py-3 pr-3 max-md:pl-3">
         | 
| 220 | 
            -
            		<div class="pl-2">
         | 
| 221 | 
             
            			<ProjectSelect />
         | 
| 222 | 
             
            		</div>
         | 
| 223 | 
             
            		<div
         | 
| @@ -245,13 +251,13 @@ | |
| 245 | 
             
            	</div>
         | 
| 246 |  | 
| 247 | 
             
            	<!-- Center column -->
         | 
| 248 | 
            -
            	<div class="relative  | 
| 249 | 
             
            		<Toaster />
         | 
| 250 | 
             
            		<div
         | 
| 251 | 
            -
            			class="flex  | 
| 252 | 
             
            		>
         | 
| 253 | 
             
            			{#each session.project.conversations as conversation, conversationIdx (conversation)}
         | 
| 254 | 
            -
            				<div class="max-sm:min-w-full">
         | 
| 255 | 
             
            					{#if compareActive}
         | 
| 256 | 
             
            						<PlaygroundConversationHeader
         | 
| 257 | 
             
            							{conversationIdx}
         | 
| @@ -266,14 +272,15 @@ | |
| 266 | 
             
            							v => (session.project.conversations[conversationIdx] = v)
         | 
| 267 | 
             
            						}
         | 
| 268 | 
             
            						{viewCode}
         | 
| 269 | 
            -
            						{compareActive}
         | 
| 270 | 
             
            						on:closeCode={() => (viewCode = false)}
         | 
| 271 | 
             
            					/>
         | 
| 272 | 
             
            				</div>
         | 
| 273 | 
             
            			{/each}
         | 
| 274 | 
             
            		</div>
         | 
|  | |
|  | |
| 275 | 
             
            		<div
         | 
| 276 | 
            -
            			class=" | 
| 277 | 
             
            		>
         | 
| 278 | 
             
            			<div class="flex flex-1 justify-start gap-x-2">
         | 
| 279 | 
             
            				{#if !compareActive}
         | 
| @@ -285,7 +292,7 @@ | |
| 285 | 
             
            						<div class="text-black dark:text-white">
         | 
| 286 | 
             
            							<IconSettings />
         | 
| 287 | 
             
            						</div>
         | 
| 288 | 
            -
            						{!viewSettings ? "Settings" : "Hide | 
| 289 | 
             
            					</button>
         | 
| 290 | 
             
            				{/if}
         | 
| 291 | 
             
            				<Tooltip>
         | 
| @@ -297,9 +304,11 @@ | |
| 297 | 
             
            					Clear conversation
         | 
| 298 | 
             
            				</Tooltip>
         | 
| 299 | 
             
            			</div>
         | 
| 300 | 
            -
            			<div | 
|  | |
|  | |
| 301 | 
             
            				{#each generationStats as { latency, generatedTokensCount }}
         | 
| 302 | 
            -
            					<span | 
| 303 | 
             
            				{/each}
         | 
| 304 | 
             
            			</div>
         | 
| 305 | 
             
            			<div class="flex flex-1 justify-end gap-x-2">
         | 
| @@ -346,74 +355,83 @@ | |
| 346 |  | 
| 347 | 
             
            	<!-- Last column -->
         | 
| 348 | 
             
            	{#if !compareActive}
         | 
| 349 | 
            -
            		<div class= | 
| 350 | 
             
            			<div
         | 
| 351 | 
            -
            				class= | 
|  | |
|  | |
|  | |
| 352 | 
             
            			>
         | 
| 353 | 
            -
            				<div | 
| 354 | 
            -
            					 | 
| 355 | 
            -
             | 
| 356 | 
            -
             | 
| 357 | 
            -
             | 
| 358 | 
            -
             | 
| 359 | 
            -
             | 
| 360 | 
            -
             | 
| 361 | 
            -
             | 
| 362 | 
            -
             | 
| 363 | 
            -
             | 
| 364 | 
            -
             | 
| 365 | 
            -
             | 
| 366 | 
            -
            							 | 
| 367 | 
            -
             | 
| 368 | 
            -
             | 
| 369 | 
            -
             | 
| 370 | 
            -
             | 
| 371 | 
            -
             | 
|  | |
|  | |
|  | |
|  | |
| 372 | 
             
            					</div>
         | 
| 373 | 
            -
            				</div>
         | 
| 374 |  | 
| 375 | 
            -
             | 
| 376 |  | 
| 377 | 
            -
             | 
| 378 | 
            -
            					<button
         | 
| 379 | 
            -
            						onclick={() => showShareModal(session.project)}
         | 
| 380 | 
            -
            						class="flex items-center gap-1 text-sm text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
         | 
| 381 | 
            -
            					>
         | 
| 382 | 
            -
            						<IconShare class="text-xs" />
         | 
| 383 | 
            -
            						Share
         | 
| 384 | 
            -
            					</button>
         | 
| 385 | 
            -
            					{#if token.value}
         | 
| 386 | 
             
            						<button
         | 
| 387 | 
            -
            							onclick={ | 
| 388 | 
             
            							class="flex items-center gap-1 text-sm text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
         | 
| 389 | 
             
            						>
         | 
| 390 | 
            -
            							< | 
| 391 | 
            -
             | 
| 392 | 
            -
            									fill="currentColor"
         | 
| 393 | 
            -
            									d="M23.216 4H26V2h-7v6h2V5.096A11.96 11.96 0 0 1 28 16c0 6.617-5.383 12-12 12v2c7.72 0 14-6.28 14-14c0-5.009-2.632-9.512-6.784-12"
         | 
| 394 | 
            -
            								/>
         | 
| 395 | 
            -
            								<path fill="currentColor" d="M16 20a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3M15 9h2v9h-2z" /><path
         | 
| 396 | 
            -
            									fill="currentColor"
         | 
| 397 | 
            -
            									d="M16 4V2C8.28 2 2 8.28 2 16c0 4.977 2.607 9.494 6.784 12H6v2h7v-6h-2v2.903A11.97 11.97 0 0 1 4 16C4 9.383 9.383 4 16 4"
         | 
| 398 | 
            -
            								/>
         | 
| 399 | 
            -
            							</svg>
         | 
| 400 | 
            -
            							Reset token
         | 
| 401 | 
             
            						</button>
         | 
| 402 | 
            -
             | 
| 403 | 
            -
             | 
| 404 | 
            -
             | 
| 405 | 
            -
             | 
| 406 | 
            -
             | 
| 407 | 
            -
             | 
| 408 | 
            -
             | 
| 409 | 
            -
             | 
| 410 | 
            -
             | 
| 411 | 
            -
             | 
| 412 | 
            -
             | 
| 413 | 
            -
             | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 414 | 
             
            					</div>
         | 
| 415 | 
            -
             | 
| 416 | 
            -
             | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 417 | 
             
            					</div>
         | 
| 418 | 
             
            				</div>
         | 
| 419 | 
             
            			</div>
         | 
|  | |
| 177 | 
             
            		const RE_HF_TOKEN = /\bhf_[a-zA-Z0-9]{34}\b/;
         | 
| 178 | 
             
            		if (RE_HF_TOKEN.test(submittedHfToken)) {
         | 
| 179 | 
             
            			token.value = submittedHfToken;
         | 
| 180 | 
            +
            			// TODO: Only submit when previous action was trying to submit
         | 
| 181 | 
            +
            			// submit();
         | 
| 182 | 
             
            		} else {
         | 
| 183 | 
             
            			alert("Please provide a valid HF token.");
         | 
| 184 | 
             
            		}
         | 
|  | |
| 212 |  | 
| 213 | 
             
            <!-- svelte-ignore a11y_no_static_element_interactions -->
         | 
| 214 | 
             
            <div
         | 
| 215 | 
            +
            	class={[
         | 
| 216 | 
            +
            		"motion-safe:animate-fade-in grid h-dvh divide-gray-200 overflow-hidden bg-gray-100/50",
         | 
| 217 | 
            +
            		"max-md:grid-rows-[120px_1fr] max-md:divide-y",
         | 
| 218 | 
            +
            		"dark:divide-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:[color-scheme:dark]",
         | 
| 219 | 
            +
            		compareActive
         | 
| 220 | 
            +
            			? "md:grid-cols-[clamp(220px,20%,350px)_minmax(0,1fr)]"
         | 
| 221 | 
            +
            			: "md:grid-cols-[clamp(220px,20%,350px)_minmax(0,1fr)_clamp(270px,25%,300px)]",
         | 
| 222 | 
            +
            	]}
         | 
| 223 | 
             
            >
         | 
| 224 | 
             
            	<!-- First column -->
         | 
| 225 | 
             
            	<div class="flex flex-col gap-2 overflow-y-auto py-3 pr-3 max-md:pl-3">
         | 
| 226 | 
            +
            		<div class="md:pl-2">
         | 
| 227 | 
             
            			<ProjectSelect />
         | 
| 228 | 
             
            		</div>
         | 
| 229 | 
             
            		<div
         | 
|  | |
| 251 | 
             
            	</div>
         | 
| 252 |  | 
| 253 | 
             
            	<!-- Center column -->
         | 
| 254 | 
            +
            	<div class="relative flex h-full flex-col overflow-hidden" onkeydown={onKeydown}>
         | 
| 255 | 
             
            		<Toaster />
         | 
| 256 | 
             
            		<div
         | 
| 257 | 
            +
            			class="flex flex-1 divide-x divide-gray-200 overflow-x-auto overflow-y-hidden *:w-full max-sm:w-dvw md:pt-3 dark:divide-gray-800"
         | 
| 258 | 
             
            		>
         | 
| 259 | 
             
            			{#each session.project.conversations as conversation, conversationIdx (conversation)}
         | 
| 260 | 
            +
            				<div class="flex h-full flex-col overflow-hidden max-sm:min-w-full">
         | 
| 261 | 
             
            					{#if compareActive}
         | 
| 262 | 
             
            						<PlaygroundConversationHeader
         | 
| 263 | 
             
            							{conversationIdx}
         | 
|  | |
| 272 | 
             
            							v => (session.project.conversations[conversationIdx] = v)
         | 
| 273 | 
             
            						}
         | 
| 274 | 
             
            						{viewCode}
         | 
|  | |
| 275 | 
             
            						on:closeCode={() => (viewCode = false)}
         | 
| 276 | 
             
            					/>
         | 
| 277 | 
             
            				</div>
         | 
| 278 | 
             
            			{/each}
         | 
| 279 | 
             
            		</div>
         | 
| 280 | 
            +
             | 
| 281 | 
            +
            		<!-- Bottom bar -->
         | 
| 282 | 
             
            		<div
         | 
| 283 | 
            +
            			class="relative mt-auto flex h-20 shrink-0 items-center justify-center gap-2 overflow-hidden border-t border-gray-200 px-3 whitespace-nowrap dark:border-gray-800"
         | 
| 284 | 
             
            		>
         | 
| 285 | 
             
            			<div class="flex flex-1 justify-start gap-x-2">
         | 
| 286 | 
             
            				{#if !compareActive}
         | 
|  | |
| 292 | 
             
            						<div class="text-black dark:text-white">
         | 
| 293 | 
             
            							<IconSettings />
         | 
| 294 | 
             
            						</div>
         | 
| 295 | 
            +
            						{!viewSettings ? "Settings" : "Hide"}
         | 
| 296 | 
             
            					</button>
         | 
| 297 | 
             
            				{/if}
         | 
| 298 | 
             
            				<Tooltip>
         | 
|  | |
| 304 | 
             
            					Clear conversation
         | 
| 305 | 
             
            				</Tooltip>
         | 
| 306 | 
             
            			</div>
         | 
| 307 | 
            +
            			<div
         | 
| 308 | 
            +
            				class="pointer-events-none absolute inset-0 flex flex-1 shrink-0 items-center justify-around gap-x-8 text-center text-sm text-gray-500 max-xl:hidden"
         | 
| 309 | 
            +
            			>
         | 
| 310 | 
             
            				{#each generationStats as { latency, generatedTokensCount }}
         | 
| 311 | 
            +
            					<span>{generatedTokensCount} tokens · Latency {latency}ms</span>
         | 
| 312 | 
             
            				{/each}
         | 
| 313 | 
             
            			</div>
         | 
| 314 | 
             
            			<div class="flex flex-1 justify-end gap-x-2">
         | 
|  | |
| 355 |  | 
| 356 | 
             
            	<!-- Last column -->
         | 
| 357 | 
             
            	{#if !compareActive}
         | 
| 358 | 
            +
            		<div class={[viewSettings && "max-md:fixed max-md:inset-0 max-md:bottom-20 max-md:backdrop-blur-lg"]}>
         | 
| 359 | 
             
            			<div
         | 
| 360 | 
            +
            				class={[
         | 
| 361 | 
            +
            					"flex h-full flex-col  p-3 max-md:absolute max-md:inset-x-0 max-md:bottom-0",
         | 
| 362 | 
            +
            					viewSettings ? "max-md:fixed" : "max-md:hidden",
         | 
| 363 | 
            +
            				]}
         | 
| 364 | 
             
            			>
         | 
| 365 | 
            +
            				<div
         | 
| 366 | 
            +
            					class="flex flex-1 flex-col gap-6 overflow-y-hidden rounded-xl border border-gray-200/80 bg-white bg-linear-to-b from-white via-white p-3 shadow-xs dark:border-white/5 dark:bg-gray-900 dark:from-gray-800/40 dark:via-gray-800/40"
         | 
| 367 | 
            +
            				>
         | 
| 368 | 
            +
            					<div class="flex flex-col gap-2">
         | 
| 369 | 
            +
            						<ModelSelector bind:conversation={session.project.conversations[0]!} />
         | 
| 370 | 
            +
            						<div class="flex items-center gap-2 self-end px-2 text-xs whitespace-nowrap">
         | 
| 371 | 
            +
            							<button
         | 
| 372 | 
            +
            								class="flex items-center gap-0.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
         | 
| 373 | 
            +
            								onclick={() => (selectCompareModelOpen = true)}
         | 
| 374 | 
            +
            							>
         | 
| 375 | 
            +
            								<IconCompare />
         | 
| 376 | 
            +
            								Compare
         | 
| 377 | 
            +
            							</button>
         | 
| 378 | 
            +
            							<a
         | 
| 379 | 
            +
            								href="https://huggingface.co/{session.project.conversations[0]?.model.id}?inference_provider={session
         | 
| 380 | 
            +
            									.project.conversations[0]?.provider}"
         | 
| 381 | 
            +
            								target="_blank"
         | 
| 382 | 
            +
            								class="flex items-center gap-0.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
         | 
| 383 | 
            +
            							>
         | 
| 384 | 
            +
            								<IconExternal class="text-2xs" />
         | 
| 385 | 
            +
            								Model page
         | 
| 386 | 
            +
            							</a>
         | 
| 387 | 
            +
            						</div>
         | 
| 388 | 
             
            					</div>
         | 
|  | |
| 389 |  | 
| 390 | 
            +
            					<GenerationConfig bind:conversation={session.project.conversations[0]!} />
         | 
| 391 |  | 
| 392 | 
            +
            					<div class="mt-auto flex items-center justify-end gap-4">
         | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 393 | 
             
            						<button
         | 
| 394 | 
            +
            							onclick={() => showShareModal(session.project)}
         | 
| 395 | 
             
            							class="flex items-center gap-1 text-sm text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
         | 
| 396 | 
             
            						>
         | 
| 397 | 
            +
            							<IconShare class="text-xs" />
         | 
| 398 | 
            +
            							Share
         | 
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
| 399 | 
             
            						</button>
         | 
| 400 | 
            +
            						{#if token.value}
         | 
| 401 | 
            +
            							<button
         | 
| 402 | 
            +
            								onclick={token.reset}
         | 
| 403 | 
            +
            								class="flex items-center gap-1 text-sm text-gray-500 underline decoration-gray-300 hover:text-gray-800 dark:text-gray-400 dark:decoration-gray-600 dark:hover:text-gray-200"
         | 
| 404 | 
            +
            							>
         | 
| 405 | 
            +
            								<svg xmlns="http://www.w3.org/2000/svg" class="text-xs" width="1em" height="1em" viewBox="0 0 32 32">
         | 
| 406 | 
            +
            									<path
         | 
| 407 | 
            +
            										fill="currentColor"
         | 
| 408 | 
            +
            										d="M23.216 4H26V2h-7v6h2V5.096A11.96 11.96 0 0 1 28 16c0 6.617-5.383 12-12 12v2c7.72 0 14-6.28 14-14c0-5.009-2.632-9.512-6.784-12"
         | 
| 409 | 
            +
            									/>
         | 
| 410 | 
            +
            									<path fill="currentColor" d="M16 20a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3M15 9h2v9h-2z" /><path
         | 
| 411 | 
            +
            										fill="currentColor"
         | 
| 412 | 
            +
            										d="M16 4V2C8.28 2 2 8.28 2 16c0 4.977 2.607 9.494 6.784 12H6v2h7v-6h-2v2.903A11.97 11.97 0 0 1 4 16C4 9.383 9.383 4 16 4"
         | 
| 413 | 
            +
            									/>
         | 
| 414 | 
            +
            								</svg>
         | 
| 415 | 
            +
            								Reset token
         | 
| 416 | 
            +
            							</button>
         | 
| 417 | 
            +
            						{/if}
         | 
| 418 | 
             
            					</div>
         | 
| 419 | 
            +
             | 
| 420 | 
            +
            					<div class="mt-auto hidden">
         | 
| 421 | 
            +
            						<div class="mb-3 flex items-center justify-between gap-2">
         | 
| 422 | 
            +
            							<label for="default-range" class="block text-sm font-medium text-gray-900 dark:text-white"
         | 
| 423 | 
            +
            								>API Quota</label
         | 
| 424 | 
            +
            							>
         | 
| 425 | 
            +
            							<span
         | 
| 426 | 
            +
            								class="rounded-sm bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-800 dark:bg-gray-700 dark:text-gray-300"
         | 
| 427 | 
            +
            								>Free</span
         | 
| 428 | 
            +
            							>
         | 
| 429 | 
            +
             | 
| 430 | 
            +
            							<div class="ml-auto w-12 text-right text-sm">76%</div>
         | 
| 431 | 
            +
            						</div>
         | 
| 432 | 
            +
            						<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
         | 
| 433 | 
            +
            							<div class="h-2 rounded-full bg-black dark:bg-gray-400" style="width: 75%"></div>
         | 
| 434 | 
            +
            						</div>
         | 
| 435 | 
             
            					</div>
         | 
| 436 | 
             
            				</div>
         | 
| 437 | 
             
            			</div>
         | 
    	
        src/lib/components/share-modal.svelte
    CHANGED
    
    | @@ -53,7 +53,7 @@ | |
| 53 | 
             
            		>
         | 
| 54 | 
             
            			<!-- Content -->
         | 
| 55 | 
             
            			<div
         | 
| 56 | 
            -
            				class="relative w-xl rounded-xl bg-white shadow-sm dark:bg-gray-900"
         | 
| 57 | 
             
            				use:clickOutside={() => close()}
         | 
| 58 | 
             
            				transition:scale={{ start: 0.975, duration: 250 }}
         | 
| 59 | 
             
            			>
         | 
|  | |
| 53 | 
             
            		>
         | 
| 54 | 
             
            			<!-- Content -->
         | 
| 55 | 
             
            			<div
         | 
| 56 | 
            +
            				class="relative w-xl max-w-[calc(100dvw-2rem)] rounded-xl bg-white shadow-sm dark:bg-gray-900"
         | 
| 57 | 
             
            				use:clickOutside={() => close()}
         | 
| 58 | 
             
            				transition:scale={{ start: 0.975, duration: 250 }}
         | 
| 59 | 
             
            			>
         | 
    	
        src/lib/components/tooltip.svelte
    CHANGED
    
    | @@ -1,18 +1,20 @@ | |
| 1 | 
             
            <script lang="ts">
         | 
| 2 | 
            -
            	import { type ComponentProps, type Extracted } from "melt";
         | 
| 3 | 
             
            	import { Tooltip, type TooltipProps } from "melt/builders";
         | 
| 4 | 
             
            	import type { Snippet } from "svelte";
         | 
| 5 |  | 
| 6 | 
             
            	type FloatingConfig = NonNullable<Extracted<TooltipProps["floatingConfig"]>>;
         | 
| 7 |  | 
| 8 | 
            -
            	interface Props {
         | 
| 9 | 
             
            		children: Snippet;
         | 
| 10 | 
             
            		trigger: Snippet<[Tooltip]>;
         | 
| 11 | 
             
            		placement?: NonNullable<FloatingConfig["computePosition"]>["placement"];
         | 
| 12 | 
             
            		openDelay?: ComponentProps<TooltipProps>["openDelay"];
         | 
|  | |
| 13 | 
             
            	}
         | 
| 14 | 
            -
            	const { children, trigger, placement = "top", openDelay = 500 }: Props = $props();
         | 
| 15 |  | 
|  | |
| 16 | 
             
            	const tooltip = new Tooltip({
         | 
| 17 | 
             
            		forceVisible: true,
         | 
| 18 | 
             
            		floatingConfig: () => ({
         | 
| @@ -22,7 +24,13 @@ | |
| 22 | 
             
            				padding: 10,
         | 
| 23 | 
             
            			},
         | 
| 24 | 
             
            		}),
         | 
|  | |
|  | |
|  | |
|  | |
|  | |
| 25 | 
             
            		openDelay: () => openDelay,
         | 
|  | |
| 26 | 
             
            	});
         | 
| 27 | 
             
            </script>
         | 
| 28 |  | 
|  | |
| 1 | 
             
            <script lang="ts">
         | 
| 2 | 
            +
            	import { getters, type ComponentProps, type Extracted } from "melt";
         | 
| 3 | 
             
            	import { Tooltip, type TooltipProps } from "melt/builders";
         | 
| 4 | 
             
            	import type { Snippet } from "svelte";
         | 
| 5 |  | 
| 6 | 
             
            	type FloatingConfig = NonNullable<Extracted<TooltipProps["floatingConfig"]>>;
         | 
| 7 |  | 
| 8 | 
            +
            	interface Props extends Omit<ComponentProps<TooltipProps>, "floatingConfig"> {
         | 
| 9 | 
             
            		children: Snippet;
         | 
| 10 | 
             
            		trigger: Snippet<[Tooltip]>;
         | 
| 11 | 
             
            		placement?: NonNullable<FloatingConfig["computePosition"]>["placement"];
         | 
| 12 | 
             
            		openDelay?: ComponentProps<TooltipProps>["openDelay"];
         | 
| 13 | 
            +
            		disabled?: boolean;
         | 
| 14 | 
             
            	}
         | 
| 15 | 
            +
            	const { children, trigger, placement = "top", openDelay = 500, disabled, ...rest }: Props = $props();
         | 
| 16 |  | 
| 17 | 
            +
            	let open = $state(false);
         | 
| 18 | 
             
            	const tooltip = new Tooltip({
         | 
| 19 | 
             
            		forceVisible: true,
         | 
| 20 | 
             
            		floatingConfig: () => ({
         | 
|  | |
| 24 | 
             
            				padding: 10,
         | 
| 25 | 
             
            			},
         | 
| 26 | 
             
            		}),
         | 
| 27 | 
            +
            		open: () => open,
         | 
| 28 | 
            +
            		onOpenChange(v) {
         | 
| 29 | 
            +
            			if (disabled) open = false;
         | 
| 30 | 
            +
            			else open = v;
         | 
| 31 | 
            +
            		},
         | 
| 32 | 
             
            		openDelay: () => openDelay,
         | 
| 33 | 
            +
            		...getters(rest),
         | 
| 34 | 
             
            	});
         | 
| 35 | 
             
            </script>
         | 
| 36 |  | 
    	
        src/lib/state/session.svelte.ts
    CHANGED
    
    | @@ -91,20 +91,22 @@ class SessionState { | |
| 91 | 
             
            			const searchProviders = searchParams.getAll("provider");
         | 
| 92 | 
             
            			const searchModelIds = searchParams.getAll("modelId");
         | 
| 93 | 
             
            			const modelsFromSearch = searchModelIds.map(id => models.remote.find(model => model.id === id)).filter(Boolean);
         | 
| 94 | 
            -
            			if (modelsFromSearch.length > 0)  | 
| 95 | 
            -
             | 
| 96 | 
            -
             | 
| 97 | 
            -
             | 
| 98 | 
            -
             | 
| 99 | 
            -
             | 
| 100 | 
            -
             | 
| 101 | 
            -
             | 
| 102 | 
            -
            				 | 
| 103 | 
            -
             | 
| 104 | 
            -
            					 | 
| 105 | 
            -
             | 
| 106 | 
            -
             | 
| 107 | 
            -
             | 
|  | |
|  | |
| 108 | 
             
            			}
         | 
| 109 | 
             
            		}
         | 
| 110 |  | 
|  | |
| 91 | 
             
            			const searchProviders = searchParams.getAll("provider");
         | 
| 92 | 
             
            			const searchModelIds = searchParams.getAll("modelId");
         | 
| 93 | 
             
            			const modelsFromSearch = searchModelIds.map(id => models.remote.find(model => model.id === id)).filter(Boolean);
         | 
| 94 | 
            +
            			if (modelsFromSearch.length > 0) {
         | 
| 95 | 
            +
            				savedSession.activeProjectId = "default";
         | 
| 96 | 
            +
             | 
| 97 | 
            +
            				let min = Math.min(dp.conversations.length, modelsFromSearch.length, searchProviders.length);
         | 
| 98 | 
            +
            				min = Math.max(1, min);
         | 
| 99 | 
            +
            				const convos = dp.conversations.slice(0, min);
         | 
| 100 | 
            +
            				if (typia.is<Project["conversations"]>(convos)) dp.conversations = convos;
         | 
| 101 | 
            +
             | 
| 102 | 
            +
            				for (let i = 0; i < min; i++) {
         | 
| 103 | 
            +
            					const conversation = dp.conversations[i] ?? defaultConversation;
         | 
| 104 | 
            +
            					dp.conversations[i] = {
         | 
| 105 | 
            +
            						...conversation,
         | 
| 106 | 
            +
            						model: modelsFromSearch[i] ?? conversation.model,
         | 
| 107 | 
            +
            						provider: searchProviders[i] ?? conversation.provider,
         | 
| 108 | 
            +
            					};
         | 
| 109 | 
            +
            				}
         | 
| 110 | 
             
            			}
         | 
| 111 | 
             
            		}
         | 
| 112 |  | 
    	
        src/lib/utils/form.svelte.ts
    ADDED
    
    | @@ -0,0 +1,35 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            export type CreateFieldValidationArgs = {
         | 
| 2 | 
            +
            	validate: (v: string) => string | void | undefined;
         | 
| 3 | 
            +
            };
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            export function createFieldValidation(args: CreateFieldValidationArgs) {
         | 
| 6 | 
            +
            	let valid = $state(true);
         | 
| 7 | 
            +
            	let msg = $state<string>();
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            	const onblur = (e: Event & { currentTarget: HTMLInputElement }) => {
         | 
| 10 | 
            +
            		const v = e.currentTarget?.value;
         | 
| 11 | 
            +
            		const m = args.validate(v);
         | 
| 12 | 
            +
            		valid = !m;
         | 
| 13 | 
            +
            		msg = m ?? undefined;
         | 
| 14 | 
            +
            	};
         | 
| 15 | 
            +
             | 
| 16 | 
            +
            	const oninput = (e: Event & { currentTarget: HTMLInputElement }) => {
         | 
| 17 | 
            +
            		if (valid) return;
         | 
| 18 | 
            +
            		const v = e.currentTarget.value;
         | 
| 19 | 
            +
            		const m = args.validate(v);
         | 
| 20 | 
            +
            		msg = m ? m : undefined;
         | 
| 21 | 
            +
            	};
         | 
| 22 | 
            +
             | 
| 23 | 
            +
            	return {
         | 
| 24 | 
            +
            		get valid() {
         | 
| 25 | 
            +
            			return valid;
         | 
| 26 | 
            +
            		},
         | 
| 27 | 
            +
            		get msg() {
         | 
| 28 | 
            +
            			return msg;
         | 
| 29 | 
            +
            		},
         | 
| 30 | 
            +
            		attrs: {
         | 
| 31 | 
            +
            			onblur,
         | 
| 32 | 
            +
            			oninput,
         | 
| 33 | 
            +
            		},
         | 
| 34 | 
            +
            	};
         | 
| 35 | 
            +
            }
         | 
    	
        src/lib/utils/url.ts
    ADDED
    
    | @@ -0,0 +1,8 @@ | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | |
|  | 
|  | |
| 1 | 
            +
            export function isValidURL(url: string): boolean {
         | 
| 2 | 
            +
            	try {
         | 
| 3 | 
            +
            		new URL(url);
         | 
| 4 | 
            +
            		return true;
         | 
| 5 | 
            +
            	} catch {
         | 
| 6 | 
            +
            		return false;
         | 
| 7 | 
            +
            	}
         | 
| 8 | 
            +
            }
         | 
    	
        vite.config.ts
    CHANGED
    
    | @@ -3,6 +3,8 @@ import { defineConfig } from "vite"; | |
| 3 | 
             
            import UnpluginTypia from "@ryoppippi/unplugin-typia/vite";
         | 
| 4 | 
             
            import Icons from "unplugin-icons/vite";
         | 
| 5 |  | 
|  | |
|  | |
| 6 | 
             
            export default defineConfig({
         | 
| 7 | 
             
            	plugins: [
         | 
| 8 | 
             
            		UnpluginTypia({
         | 
| @@ -15,4 +17,7 @@ export default defineConfig({ | |
| 15 | 
             
            			autoInstall: true,
         | 
| 16 | 
             
            		}),
         | 
| 17 | 
             
            	],
         | 
|  | |
|  | |
|  | |
| 18 | 
             
            });
         | 
|  | |
| 3 | 
             
            import UnpluginTypia from "@ryoppippi/unplugin-typia/vite";
         | 
| 4 | 
             
            import Icons from "unplugin-icons/vite";
         | 
| 5 |  | 
| 6 | 
            +
            export const isDev = process.env.NODE_ENV === "development";
         | 
| 7 | 
            +
             | 
| 8 | 
             
            export default defineConfig({
         | 
| 9 | 
             
            	plugins: [
         | 
| 10 | 
             
            		UnpluginTypia({
         | 
|  | |
| 17 | 
             
            			autoInstall: true,
         | 
| 18 | 
             
            		}),
         | 
| 19 | 
             
            	],
         | 
| 20 | 
            +
            	server: {
         | 
| 21 | 
            +
            		allowedHosts: isDev ? true : undefined,
         | 
| 22 | 
            +
            	},
         | 
| 23 | 
             
            });
         | 

