Thomas G. Lopes victor HF Staff commited on
Commit
28faefd
·
unverified ·
1 Parent(s): c670717

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) <[email protected]>

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
- tick().then(() => {
5
- node.focus();
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={["flex rounded-xl border border-gray-700 bg-gray-800 p-2 shadow"]}
 
 
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.filter(m => m.content?.trim())}
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
- <p class="line-clamp-2 text-sm">{msg.content}</p>
 
 
 
 
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 items-center justify-between px-2 pt-6 pb-4">
200
- <h2 class="flex items-baseline gap-2 font-semibold">
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, compareActive }: Props = $props();
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 {compareActive
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
- let testing = $state(false);
 
 
 
 
 
 
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
- <button
208
- data-form="submit"
209
- type="submit"
210
- class="rounded-lg bg-black px-5 py-2.5 text-sm font-medium text-white
211
- hover:bg-gray-900 focus:ring-4 focus:ring-gray-300 focus:outline-none
212
- disabled:!bg-black dark:border-gray-700
213
- dark:bg-gray-800 dark:hover:bg-gray-700 dark:focus:ring-gray-700 dark:disabled:!bg-gray-800"
214
- disabled={testing}
215
- >
216
- Submit
217
- </button>
 
 
 
 
 
 
 
 
 
 
 
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
- tabindex="2"
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
- class="fixed inset-0 z-10 flex h-screen items-start justify-center bg-black/85 pt-32"
75
- bind:this={backdropEl}
76
- onclick={handleBackdropClick}
77
- >
78
- <div class="flex w-full max-w-[600px] items-start justify-center overflow-hidden p-10 text-left whitespace-nowrap">
 
 
 
 
 
 
 
 
 
 
79
  <div
80
- class="flex h-full w-full 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"
 
 
81
  >
82
- <div class="flex items-center border-b px-3 dark:border-gray-800">
83
- <div class="mr-2 text-sm">
84
- <IconSearch />
85
- </div>
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
- data-model
101
- {...combobox.getOption(model.id)}
102
- >
103
- {#if trending}
104
- <div class=" mr-1.5 size-4 text-yellow-400">
105
- <IconStar />
106
- </div>
107
- {/if}
108
 
109
- {#if modelName}
110
- <span class="inline-flex items-center">
111
- <span class="text-gray-500 dark:text-gray-400">{nameSpace}</span>
112
- <span class="mx-1 text-gray-300 dark:text-gray-700">/</span>
113
- <span class="text-black dark:text-white">{modelName}</span>
114
- </span>
115
- {:else}
116
- <span class="text-black dark:text-white">{nameSpace}</span>
117
- {/if}
118
 
119
- {#if "pipeline_tag" in model && model.pipeline_tag === "image-text-to-text"}
120
- <Tooltip openDelay={100}>
121
- {#snippet trigger(tooltip)}
122
- <div
123
- 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"
124
- {...tooltip.trigger}
125
- >
126
- <IconEye class="size-3.5" />
127
- </div>
128
- {/snippet}
129
- Image text-to-text
130
- </Tooltip>
131
- {/if}
132
 
133
- {#if isCustom(model)}
134
- <Tooltip openDelay={100}>
135
- {#snippet trigger(tooltip)}
136
- <div
137
- 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"
138
- {...tooltip.trigger}
139
- >
140
- <IconCube class="size-3.5" />
141
- </div>
142
- {/snippet}
143
- Custom Model
144
- </Tooltip>
145
- <Tooltip>
146
- {#snippet trigger(tooltip)}
147
- <button
148
- class="mr-1 ml-auto grid size-4.5 place-items-center rounded-sm bg-gray-100 text-xs
149
  hover:bg-gray-200 dark:bg-gray-600 dark:hover:bg-gray-500"
150
- aria-label="Add custom model"
151
- {...tooltip.trigger}
152
- onclick={e => {
153
- e.stopPropagation();
154
- onClose?.();
155
- openCustomModelConfig({
156
- model,
157
- onSubmit: model => {
158
- onModelSelect?.(model.id);
159
- },
160
- });
161
- }}
162
- >
163
- <IconEdit class="size-3" />
164
- </button>
165
- {/snippet}
166
- <span class="text-sm">Edit</span>
167
- </Tooltip>
168
- {/if}
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
- {#if other.length > 0}
198
- <div class="px-2 py-1.5 text-xs font-medium text-gray-500">Other models</div>
199
- {#each other as model}
200
- {@render modelEntry(model, false)}
201
- {/each}
202
- {/if}
203
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="motion-safe:animate-fade-in grid h-dvh divide-gray-200 overflow-hidden bg-gray-100/50 max-md:grid-rows-[120px_1fr] max-md:divide-y dark:divide-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:[color-scheme:dark] {compareActive
215
- ? 'md:grid-cols-[clamp(220px,20%,350px)_minmax(0,1fr)]'
216
- : 'md:grid-cols-[clamp(220px,20%,350px)_minmax(0,1fr)_clamp(270px,25%,300px)]'}"
 
 
 
 
 
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 divide-y divide-gray-200 dark:divide-gray-800" onkeydown={onKeydown}>
249
  <Toaster />
250
  <div
251
- class="flex h-[calc(100dvh-5rem-120px)] divide-x divide-gray-200 overflow-x-auto overflow-y-hidden *:w-full max-sm:w-dvw md:h-[calc(100dvh-5rem)] md:pt-3 dark:divide-gray-800"
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="fixed inset-x-0 bottom-0 flex h-20 items-center justify-center gap-2 overflow-hidden px-3 whitespace-nowrap md:absolute"
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 Settings"}
289
  </button>
290
  {/if}
291
  <Tooltip>
@@ -297,9 +304,11 @@
297
  Clear conversation
298
  </Tooltip>
299
  </div>
300
- <div class="flex flex-1 shrink-0 items-center justify-center gap-x-8 text-center text-sm text-gray-500">
 
 
301
  {#each generationStats as { latency, generatedTokensCount }}
302
- <span class="max-xl:hidden">{generatedTokensCount} tokens · Latency {latency}ms</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="flex flex-col p-3 {viewSettings ? 'max-md:fixed' : 'max-md:hidden'} max-md:inset-x-0 max-md:bottom-20">
350
  <div
351
- 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"
 
 
 
352
  >
353
- <div class="flex flex-col gap-2">
354
- <ModelSelector bind:conversation={session.project.conversations[0]!} />
355
- <div class="flex items-center gap-2 self-end px-2 text-xs whitespace-nowrap">
356
- <button
357
- class="flex items-center gap-0.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
358
- onclick={() => (selectCompareModelOpen = true)}
359
- >
360
- <IconCompare />
361
- Compare
362
- </button>
363
- <a
364
- href="https://huggingface.co/{session.project.conversations[0]?.model.id}?inference_provider={session
365
- .project.conversations[0]?.provider}"
366
- target="_blank"
367
- class="flex items-center gap-0.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
368
- >
369
- <IconExternal class="text-2xs" />
370
- Model page
371
- </a>
 
 
 
 
372
  </div>
373
- </div>
374
 
375
- <GenerationConfig bind:conversation={session.project.conversations[0]!} />
376
 
377
- <div class="mt-auto flex items-center justify-end gap-4">
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={token.reset}
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
- <svg xmlns="http://www.w3.org/2000/svg" class="text-xs" width="1em" height="1em" viewBox="0 0 32 32">
391
- <path
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
- {/if}
403
- </div>
404
-
405
- <div class="mt-auto hidden">
406
- <div class="mb-3 flex items-center justify-between gap-2">
407
- <label for="default-range" class="block text-sm font-medium text-gray-900 dark:text-white">API Quota</label>
408
- <span
409
- 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"
410
- >Free</span
411
- >
412
-
413
- <div class="ml-auto w-12 text-right text-sm">76%</div>
 
 
 
 
 
 
414
  </div>
415
- <div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
416
- <div class="h-2 rounded-full bg-black dark:bg-gray-400" style="width: 75%"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) savedSession.activeProjectId = "default";
95
-
96
- let min = Math.min(dp.conversations.length, modelsFromSearch.length, searchProviders.length);
97
- min = Math.max(1, min);
98
- const convos = dp.conversations.slice(0, min);
99
- if (typia.is<Project["conversations"]>(convos)) dp.conversations = convos;
100
-
101
- for (let i = 0; i < min; i++) {
102
- const conversation = dp.conversations[i] ?? defaultConversation;
103
- dp.conversations[i] = {
104
- ...conversation,
105
- model: modelsFromSearch[i] ?? conversation.model,
106
- provider: searchProviders[i] ?? conversation.provider,
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
  });