Thomas G. Lopes commited on
Commit
7991bc0
·
unverified ·
2 Parent(s): 2a77df2 2a3aa41

Img txt 2 txt (#67)

Browse files
src/lib/components/debug-menu.svelte CHANGED
@@ -8,6 +8,7 @@
8
  import { showQuotaModal } from "./quota-modal.svelte";
9
  import type { ToastData } from "./toaster.svelte.js";
10
  import { addToast } from "./toaster.svelte.js";
 
11
 
12
  let innerWidth = $state<number>();
13
  let innerHeight = $state<number>();
@@ -31,6 +32,12 @@
31
  console.log(session.$);
32
  },
33
  },
 
 
 
 
 
 
34
  {
35
  label: "Test prompt",
36
  cb: async () => {
 
8
  import { showQuotaModal } from "./quota-modal.svelte";
9
  import type { ToastData } from "./toaster.svelte.js";
10
  import { addToast } from "./toaster.svelte.js";
11
+ import { models } from "$lib/state/models.svelte";
12
 
13
  let innerWidth = $state<number>();
14
  let innerHeight = $state<number>();
 
32
  console.log(session.$);
33
  },
34
  },
35
+ {
36
+ label: "Log models to console",
37
+ cb: () => {
38
+ console.log(models.all);
39
+ },
40
+ },
41
  {
42
  label: "Test prompt",
43
  cb: async () => {
src/lib/components/inference-playground/conversation.svelte CHANGED
@@ -1,11 +1,11 @@
1
  <script lang="ts">
2
- import { run } from "svelte/legacy";
3
-
4
  import type { Conversation } from "$lib/types.js";
5
 
6
  import IconPlus from "~icons/carbon/add";
7
  import CodeSnippets from "./code-snippets.svelte";
8
  import Message from "./message.svelte";
 
 
9
 
10
  interface Props {
11
  conversation: Conversation;
@@ -15,35 +15,20 @@
15
  }
16
 
17
  let { conversation = $bindable(), loading, viewCode, compareActive }: Props = $props();
18
-
19
- let shouldScrollToBottom = $state(true);
20
- let isProgrammaticScroll = $state(true);
21
- let conversationLength = $state(conversation.messages.length);
22
-
23
  let messageContainer: HTMLDivElement | null = $state(null);
24
-
25
- function scrollToBottom() {
26
- if (messageContainer) {
27
- isProgrammaticScroll = true;
28
- messageContainer.scrollTop = messageContainer.scrollHeight;
29
- }
30
- }
31
-
32
- run(() => {
33
- if (conversation.messages.at(-1)) {
34
- if (shouldScrollToBottom) {
35
- scrollToBottom();
36
- }
37
- }
38
  });
39
 
40
- run(() => {
41
- if (conversation.messages.length !== conversationLength) {
42
- // enable automatic scrolling when new message was added
43
- conversationLength = conversation.messages.length;
44
- shouldScrollToBottom = true;
 
45
  }
46
- });
47
 
48
  function addMessage() {
49
  const msgs = conversation.messages.slice();
@@ -68,19 +53,13 @@
68
  : 'max-h-[calc(100dvh-5.8rem-2.5rem-75px)] md:max-h-[calc(100dvh-5.8rem)]'}"
69
  class:animate-pulse={loading && !conversation.streaming}
70
  bind:this={messageContainer}
71
- onscroll={() => {
72
- // disable automatic scrolling is user initiates scroll
73
- if (!isProgrammaticScroll) {
74
- shouldScrollToBottom = false;
75
- }
76
- isProgrammaticScroll = false;
77
- }}
78
  >
79
  {#if !viewCode}
80
  {#each conversation.messages as _msg, idx}
81
  <Message
82
- bind:content={conversation.messages[idx]!.content}
83
- role={conversation.messages[idx]!.role}
84
  autofocus={idx === conversation.messages.length - 1}
85
  {loading}
86
  onDelete={() => deleteMessage(idx)}
 
1
  <script lang="ts">
 
 
2
  import type { Conversation } from "$lib/types.js";
3
 
4
  import IconPlus from "~icons/carbon/add";
5
  import CodeSnippets from "./code-snippets.svelte";
6
  import Message from "./message.svelte";
7
+ import { ScrollState } from "$lib/spells/scroll-state.svelte";
8
+ import { watch } from "runed";
9
 
10
  interface Props {
11
  conversation: Conversation;
 
15
  }
16
 
17
  let { conversation = $bindable(), loading, viewCode, compareActive }: Props = $props();
 
 
 
 
 
18
  let messageContainer: HTMLDivElement | null = $state(null);
19
+ const scrollState = new ScrollState({
20
+ element: () => messageContainer,
21
+ offset: { bottom: 100 },
 
 
 
 
 
 
 
 
 
 
 
22
  });
23
 
24
+ watch(
25
+ () => conversation.messages.at(-1)?.content,
26
+ () => {
27
+ const shouldScroll = scrollState.arrived.bottom && !scrollState.isScrolling;
28
+ if (!shouldScroll) return;
29
+ scrollState.scrollToBottom();
30
  }
31
+ );
32
 
33
  function addMessage() {
34
  const msgs = conversation.messages.slice();
 
53
  : 'max-h-[calc(100dvh-5.8rem-2.5rem-75px)] md:max-h-[calc(100dvh-5.8rem)]'}"
54
  class:animate-pulse={loading && !conversation.streaming}
55
  bind:this={messageContainer}
56
+ id="test-this"
 
 
 
 
 
 
57
  >
58
  {#if !viewCode}
59
  {#each conversation.messages as _msg, idx}
60
  <Message
61
+ bind:message={conversation.messages[idx]!}
62
+ {conversation}
63
  autofocus={idx === conversation.messages.length - 1}
64
  {loading}
65
  onDelete={() => deleteMessage(idx)}
src/lib/components/inference-playground/message.svelte CHANGED
@@ -1,48 +1,140 @@
1
  <script lang="ts">
 
2
  import { TextareaAutosize } from "$lib/spells/textarea-autosize.svelte.js";
3
- import type { ConversationMessage } from "$lib/types.js";
 
 
 
 
4
 
5
  type Props = {
6
- content: ConversationMessage["content"];
7
- role: ConversationMessage["role"];
8
  loading?: boolean;
9
  autofocus?: boolean;
10
  onDelete?: () => void;
11
  };
12
 
13
- let { content = $bindable(""), role, loading, autofocus, onDelete }: Props = $props();
14
 
15
  let element = $state<HTMLTextAreaElement>();
16
  new TextareaAutosize({
17
- styleProp: "minHeight",
18
  element: () => element,
19
- input: () => content,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  });
21
  </script>
22
 
23
  <div
24
- class=" group/message group grid grid-cols-[1fr_2.5rem] items-start gap-2 border-b px-3.5 pt-4 pb-6 hover:bg-gray-100/70 @-2xl:grid-cols-[130px_1fr_2.5rem] @2xl:grid-rows-1 @2xl:gap-4 @2xl:px-6 dark:border-gray-800 dark:hover:bg-gray-800/30"
 
25
  class:pointer-events-none={loading}
 
 
26
  >
27
- <div class="col-span-2 pt-3 pb-1 text-sm font-semibold uppercase @2xl:col-span-1 @2xl:pb-2">
28
- {role}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  </div>
30
- <!-- svelte-ignore a11y_autofocus -->
31
- <!-- svelte-ignore a11y_positive_tabindex -->
32
- <textarea
33
- bind:this={element}
34
- {autofocus}
35
- bind:value={content}
36
- placeholder="Enter {role} message"
37
- class="resize-none overflow-hidden rounded-sm bg-transparent px-2 py-2.5 ring-gray-100 outline-none group-hover/message:ring-3 hover:resize-y hover:bg-white focus:resize-y focus:bg-white focus:ring-3 @2xl:px-3 dark:ring-gray-600 dark:hover:bg-gray-900 dark:focus:bg-gray-900"
38
- rows="1"
39
- tabindex="2"
40
- ></textarea>
41
- <button
42
- tabindex="0"
43
- onclick={onDelete}
44
- type="button"
45
- class="mt-1.5 size-8 rounded-lg border border-gray-200 bg-white text-xs font-medium text-gray-900 group-hover/message:block hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 focus:outline-hidden sm:hidden dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
46
- >✕</button
47
- >
48
  </div>
 
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";
5
+ import { fileToDataURL } from "$lib/utils/file.js";
6
+ import { FileUpload } from "melt/builders";
7
+ import { fade } from "svelte/transition";
8
+ import IconImage from "~icons/carbon/image-reference";
9
 
10
  type Props = {
11
+ conversation: Conversation;
12
+ message: ConversationMessage;
13
  loading?: boolean;
14
  autofocus?: boolean;
15
  onDelete?: () => void;
16
  };
17
 
18
+ let { message = $bindable(), conversation, loading, autofocus, onDelete }: Props = $props();
19
 
20
  let element = $state<HTMLTextAreaElement>();
21
  new TextareaAutosize({
 
22
  element: () => element,
23
+ input: () => message.content ?? "",
24
+ });
25
+
26
+ const canUploadImgs = $derived(
27
+ message.role === "user" && conversation.model.pipeline_tag === PipelineTag.ImageTextToText
28
+ );
29
+ const fileUpload = new FileUpload({
30
+ accept: "image/*",
31
+ async onAccept(file) {
32
+ if (!message.images) message.images = [];
33
+
34
+ const dataUrl = await fileToDataURL(file);
35
+ if (message.images.includes(dataUrl)) return;
36
+
37
+ message.images.push(await fileToDataURL(file));
38
+ // We're dealing with files ourselves, so we don't want fileUpload to have any internal state,
39
+ // to avoid conflicts
40
+ fileUpload.clear();
41
+ },
42
+ disabled: () => !canUploadImgs,
43
  });
44
  </script>
45
 
46
  <div
47
+ class="group/message group relative flex flex-col items-start gap-x-4 gap-y-2 border-b px-3.5 pt-4 pb-6 hover:bg-gray-100/70
48
+ @2xl:px-6 dark:border-gray-800 dark:hover:bg-gray-800/30"
49
  class:pointer-events-none={loading}
50
+ {...fileUpload.dropzone}
51
+ onclick={undefined}
52
  >
53
+ <div class=" flex w-full flex-col items-start gap-x-4 gap-y-2 @2xl:flex-row">
54
+ {#if fileUpload.isDragging}
55
+ <div
56
+ class="absolute inset-2 z-10 flex flex-col items-center justify-center rounded-xl bg-gray-800/50 backdrop-blur-md"
57
+ transition:fade={{ duration: 100 }}
58
+ >
59
+ <IconImage />
60
+ <p>Drop the image here to upload</p>
61
+ </div>
62
+ {/if}
63
+
64
+ <div class="pt-3 text-sm font-semibold uppercase @2xl:basis-[130px]">
65
+ {message.role}
66
+ </div>
67
+ <div class="flex w-full gap-4">
68
+ <!-- svelte-ignore a11y_autofocus -->
69
+ <!-- svelte-ignore a11y_positive_tabindex -->
70
+ <textarea
71
+ bind:this={element}
72
+ {autofocus}
73
+ bind:value={message.content}
74
+ placeholder="Enter {message.role} message"
75
+ 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"
76
+ rows="1"
77
+ tabindex="2"
78
+ ></textarea>
79
+
80
+ {#if canUploadImgs}
81
+ <Tooltip openDelay={250}>
82
+ {#snippet trigger(tooltip)}
83
+ <button
84
+ tabindex="0"
85
+ type="button"
86
+ class="mt-1.5 -mr-2 grid size-8 place-items-center rounded-lg border border-gray-200 bg-white text-xs font-medium
87
+ text-gray-900 group-focus-within/message:visible group-hover/message:visible
88
+ hover:bg-gray-100 hover:text-blue-700 focus:z-10
89
+ focus:ring-4 focus:ring-gray-100 focus:outline-hidden sm:invisible dark:border-gray-600
90
+ dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
91
+ {...tooltip.trigger}
92
+ {...fileUpload.trigger}
93
+ >
94
+ <IconImage />
95
+ </button>
96
+ <input {...fileUpload.input} />
97
+ {/snippet}
98
+ Add image
99
+ </Tooltip>
100
+ {/if}
101
+
102
+ <Tooltip>
103
+ {#snippet trigger(tooltip)}
104
+ <button
105
+ tabindex="0"
106
+ onclick={onDelete}
107
+ type="button"
108
+ class="mt-1.5 size-8 rounded-lg border border-gray-200 bg-white text-xs font-medium text-gray-900
109
+ group-focus-within/message:visible group-hover/message:visible hover:bg-gray-100
110
+ hover:text-blue-700 focus:z-10 focus:ring-4
111
+ focus:ring-gray-100 focus:outline-hidden sm:invisible dark:border-gray-600 dark:bg-gray-800
112
+ dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus:ring-gray-700"
113
+ {...tooltip.trigger}
114
+ >
115
+
116
+ </button>
117
+ {/snippet}
118
+ Delete
119
+ </Tooltip>
120
+ </div>
121
  </div>
122
+ {#if message.images?.length}
123
+ <div class="mt-2">
124
+ <div class="flex items-center gap-2">
125
+ {#each message.images as img (img)}
126
+ <div class="group/img relative">
127
+ <img src={img} alt="uploaded" class="size-12 rounded-lg object-cover" />
128
+ <button
129
+ type="button"
130
+ onclick={() => (message.images = message.images?.filter(i => i !== img))}
131
+ class="invisible absolute -top-1 -right-1 grid size-5 place-items-center rounded-full bg-gray-800 text-xs text-white group-hover/img:visible hover:bg-gray-700"
132
+ >
133
+
134
+ </button>
135
+ </div>
136
+ {/each}
137
+ </div>
138
+ </div>
139
+ {/if}
140
  </div>
src/lib/components/inference-playground/model-selector-modal.svelte CHANGED
@@ -9,6 +9,7 @@
9
  import { watch } from "runed";
10
  import IconSearch from "~icons/carbon/search";
11
  import IconStar from "~icons/carbon/star";
 
12
 
13
  interface Props {
14
  onModelSelect?: (model: string) => void;
@@ -136,7 +137,7 @@
136
  }}
137
  >
138
  {#if trending}
139
- <div class="lucide lucide-star mr-1.5 size-4 text-yellow-400">
140
  <IconStar />
141
  </div>
142
  {/if}
@@ -145,6 +146,13 @@
145
  class="mx-1 text-gray-300 dark:text-gray-700">/</span
146
  ><span class="text-black dark:text-white">{modelName}</span></span
147
  >
 
 
 
 
 
 
 
148
  </button>
149
  {/snippet}
150
  {#if trending.length > 0}
 
9
  import { watch } from "runed";
10
  import IconSearch from "~icons/carbon/search";
11
  import IconStar from "~icons/carbon/star";
12
+ import IconEye from "~icons/carbon/view";
13
 
14
  interface Props {
15
  onModelSelect?: (model: string) => void;
 
137
  }}
138
  >
139
  {#if trending}
140
+ <div class=" mr-1.5 size-4 text-yellow-400">
141
  <IconStar />
142
  </div>
143
  {/if}
 
146
  class="mx-1 text-gray-300 dark:text-gray-700">/</span
147
  ><span class="text-black dark:text-white">{modelName}</span></span
148
  >
149
+ {#if model.pipeline_tag === "image-text-to-text"}
150
+ <div
151
+ 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"
152
+ >
153
+ <IconEye class="size-3.5" />
154
+ </div>
155
+ {/if}
156
  </button>
157
  {/snippet}
158
  {#if trending.length > 0}
src/lib/components/inference-playground/utils.ts CHANGED
@@ -1,8 +1,29 @@
1
- import type { Conversation, ModelWithTokenizer } from "$lib/types.js";
2
- import type { InferenceSnippet } from "@huggingface/tasks";
3
  import { type ChatCompletionOutputMessage } from "@huggingface/tasks";
4
 
5
  import { HfInference, snippets, type InferenceProvider } from "@huggingface/inference";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  export async function handleStreamingResponse(
8
  hf: HfInference,
@@ -19,7 +40,7 @@ export async function handleStreamingResponse(
19
  for await (const chunk of hf.chatCompletionStream(
20
  {
21
  model: model.id,
22
- messages,
23
  provider: conversation.provider,
24
  ...conversation.config,
25
  },
@@ -44,7 +65,7 @@ export async function handleNonStreamingResponse(
44
 
45
  const response = await hf.chatCompletion({
46
  model: model.id,
47
- messages,
48
  provider: conversation.provider,
49
  ...conversation.config,
50
  });
 
1
+ import type { Conversation, ConversationMessage, ModelWithTokenizer } from "$lib/types.js";
2
+ import type { ChatCompletionInputMessage, InferenceSnippet } from "@huggingface/tasks";
3
  import { type ChatCompletionOutputMessage } from "@huggingface/tasks";
4
 
5
  import { HfInference, snippets, type InferenceProvider } from "@huggingface/inference";
6
+ type ChatCompletionInputMessageChunk =
7
+ NonNullable<ChatCompletionInputMessage["content"]> extends string | (infer U)[] ? U : never;
8
+
9
+ function parseMessage(message: ConversationMessage): ChatCompletionInputMessage {
10
+ if (!message.images) return message;
11
+ return {
12
+ ...message,
13
+ content: [
14
+ {
15
+ type: "text",
16
+ text: message.content ?? "",
17
+ },
18
+ ...message.images.map(img => {
19
+ return {
20
+ type: "image_url",
21
+ image_url: { url: img },
22
+ } satisfies ChatCompletionInputMessageChunk;
23
+ }),
24
+ ],
25
+ };
26
+ }
27
 
28
  export async function handleStreamingResponse(
29
  hf: HfInference,
 
40
  for await (const chunk of hf.chatCompletionStream(
41
  {
42
  model: model.id,
43
+ messages: messages.map(parseMessage),
44
  provider: conversation.provider,
45
  ...conversation.config,
46
  },
 
65
 
66
  const response = await hf.chatCompletion({
67
  model: model.id,
68
+ messages: messages.map(parseMessage),
69
  provider: conversation.provider,
70
  ...conversation.config,
71
  });
src/lib/components/tooltip.svelte ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { Tooltip, type TooltipProps } from "melt/builders";
3
+ import { type ComponentProps, type Extracted } from "melt";
4
+ import type { Snippet } from "svelte";
5
+
6
+ interface Props {
7
+ children: Snippet;
8
+ trigger: Snippet<[Tooltip]>;
9
+ placement?: NonNullable<Extracted<TooltipProps["computePositionOptions"]>>["placement"];
10
+ openDelay?: ComponentProps<TooltipProps>["openDelay"];
11
+ }
12
+ const { children, trigger, placement = "top", openDelay }: Props = $props();
13
+
14
+ const tooltip = new Tooltip({
15
+ forceVisible: true,
16
+ computePositionOptions: () => ({ placement }),
17
+ openDelay: () => openDelay,
18
+ });
19
+ </script>
20
+
21
+ {@render trigger(tooltip)}
22
+
23
+ <div {...tooltip.content} class="rounded-xl bg-white p-0 shadow-xl dark:bg-gray-700">
24
+ <div {...tooltip.arrow} class="rounded-tl"></div>
25
+ <p class="px-4 py-1 text-gray-700 dark:text-white">{@render children()}</p>
26
+ </div>
27
+
28
+ <style>
29
+ [data-melt-tooltip-content] {
30
+ border: 0;
31
+
32
+ position: absolute;
33
+ pointer-events: none;
34
+ opacity: 0;
35
+
36
+ transform: scale(0.9);
37
+
38
+ transition: 0.3s;
39
+ transition-property: opacity, transform;
40
+ }
41
+
42
+ [data-melt-tooltip-content][data-open] {
43
+ pointer-events: auto;
44
+ opacity: 1;
45
+
46
+ transform: scale(1);
47
+ }
48
+ </style>
src/lib/spells/scroll-state.svelte.ts ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MaybeGetter } from "$lib/types.js";
2
+ import { AnimationFrames, useDebounce, useEventListener } from "runed";
3
+ import { onMount } from "svelte";
4
+ import { extract } from "./extract.svelte.js";
5
+ import { noop } from "$lib/utils/noop.js";
6
+
7
+ export interface ScrollStateOptions {
8
+ /**
9
+ * The target element.
10
+ */
11
+ element: MaybeGetter<HTMLElement | Window | Document | null | undefined>;
12
+
13
+ // /**
14
+ // * Throttle time for scroll event, it’s disabled by default.
15
+ // *
16
+ // * @default 0
17
+ // */
18
+ // throttle?: MaybeGetter<number | undefined>;
19
+
20
+ /**
21
+ * The check time when scrolling ends.
22
+ * This configuration will be setting to (throttle + idle) when the `throttle` is configured.
23
+ *
24
+ * @default 200
25
+ */
26
+ idle?: MaybeGetter<number | undefined>;
27
+
28
+ /**
29
+ * Offset arrived states by x pixels
30
+ *
31
+ */
32
+ offset?: MaybeGetter<
33
+ | {
34
+ left?: number;
35
+ right?: number;
36
+ top?: number;
37
+ bottom?: number;
38
+ }
39
+ | undefined
40
+ >;
41
+
42
+ /**
43
+ * Trigger it when scrolling.
44
+ *
45
+ */
46
+ onScroll?: (e: Event) => void;
47
+
48
+ /**
49
+ * Trigger it when scrolling ends.
50
+ *
51
+ */
52
+ onStop?: (e: Event) => void;
53
+
54
+ /**
55
+ * Listener options for scroll event.
56
+ *
57
+ * @default {capture: false, passive: true}
58
+ */
59
+ eventListenerOptions?: AddEventListenerOptions;
60
+
61
+ /**
62
+ * Optionally specify a scroll behavior of `auto` (default, not smooth scrolling) or
63
+ * `smooth` (for smooth scrolling) which takes effect when changing the `x` or `y` refs.
64
+ *
65
+ * @default 'auto'
66
+ */
67
+ behavior?: MaybeGetter<ScrollBehavior | undefined>;
68
+
69
+ /**
70
+ * On error callback
71
+ *
72
+ * Default log error to `console.error`
73
+ */
74
+ onError?: (error: unknown) => void;
75
+ }
76
+
77
+ /**
78
+ * We have to check if the scroll amount is close enough to some threshold in order to
79
+ * more accurately calculate arrivedState. This is because scrollTop/scrollLeft are non-rounded
80
+ * numbers, while scrollHeight/scrollWidth and clientHeight/clientWidth are rounded.
81
+ * https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
82
+ */
83
+ const ARRIVED_STATE_THRESHOLD_PIXELS = 1;
84
+
85
+ /**
86
+ * Reactive scroll.
87
+ *
88
+ * @see https://vueuse.org/useScroll for the inspiration behind this utility.
89
+ * @param element
90
+ * @param options
91
+ */
92
+ export class ScrollState {
93
+ #options!: ScrollStateOptions;
94
+ element = $derived(extract(this.#options.element));
95
+ // throttle = $derived(extract(this.#options.throttle, 0));
96
+ idle = $derived(extract(this.#options.idle, 200));
97
+ offset = $derived(
98
+ extract(this.#options.offset, {
99
+ left: 0,
100
+ right: 0,
101
+ top: 0,
102
+ bottom: 0,
103
+ })
104
+ );
105
+ onScroll = $derived(this.#options.onScroll ?? noop);
106
+ onStop = $derived(this.#options.onStop ?? noop);
107
+ eventListenerOptions = $derived(
108
+ this.#options.eventListenerOptions ?? {
109
+ capture: false,
110
+ passive: true,
111
+ }
112
+ );
113
+ behavior = $derived(extract(this.#options.behavior, "auto"));
114
+ onError = $derived(
115
+ this.#options.onError ??
116
+ ((e: unknown) => {
117
+ console.error(e);
118
+ })
119
+ );
120
+
121
+ /** State */
122
+ internalX = $state(0);
123
+ internalY = $state(0);
124
+
125
+ // Use a get/set pair for x and y because we want to write the value to the refs
126
+ // during a `scrollTo()` without firing additional `scrollTo()`s in the process.
127
+ #x = $derived(this.internalX);
128
+ get x() {
129
+ return this.#x;
130
+ }
131
+ set x(v) {
132
+ this.scrollTo(v, undefined);
133
+ }
134
+
135
+ #y = $derived(this.internalY);
136
+ get y() {
137
+ return this.#y;
138
+ }
139
+ set y(v) {
140
+ this.scrollTo(undefined, v);
141
+ }
142
+
143
+ isScrolling = $state(false);
144
+ arrived = $state({
145
+ left: true,
146
+ right: false,
147
+ top: true,
148
+ bottom: false,
149
+ });
150
+ directions = $state({
151
+ left: false,
152
+ right: false,
153
+ top: false,
154
+ bottom: false,
155
+ });
156
+
157
+ constructor(options: ScrollStateOptions) {
158
+ this.#options = options;
159
+
160
+ useEventListener(
161
+ () => this.element,
162
+ "scroll",
163
+ // throttle ? useThrottleFn(onScrollHandler, throttle, true, false) : onScrollHandler,
164
+ this.onScrollHandler,
165
+ this.eventListenerOptions
166
+ );
167
+
168
+ useEventListener(
169
+ () => this.element,
170
+ "scrollend",
171
+ e => this.onScrollEnd(e),
172
+ this.eventListenerOptions
173
+ );
174
+
175
+ onMount(() => {
176
+ this.setArrivedState();
177
+ });
178
+
179
+ // useResizeObserver(
180
+ // () => (isHtmlElement(this.element) ? this.element : null),
181
+ // () => {
182
+ // setTimeout(() => {
183
+ // this.setArrivedState();
184
+ // }, 100);
185
+ // }
186
+ // );
187
+
188
+ // overkill?
189
+ new AnimationFrames(() => this.setArrivedState());
190
+ }
191
+
192
+ setArrivedState = () => {
193
+ if (!window || !this.element) return;
194
+
195
+ const el: Element = ((this.element as Window)?.document?.documentElement ||
196
+ (this.element as Document)?.documentElement ||
197
+ (this.element as HTMLElement | SVGElement)) as Element;
198
+
199
+ const { display, flexDirection, direction } = getComputedStyle(el);
200
+ const directionMultipler = direction === "rtl" ? -1 : 1;
201
+
202
+ const scrollLeft = el.scrollLeft;
203
+ this.directions.left = scrollLeft < this.internalX;
204
+ this.directions.right = scrollLeft > this.internalX;
205
+
206
+ const left = scrollLeft * directionMultipler <= (this.offset.left || 0);
207
+ const right =
208
+ scrollLeft * directionMultipler + el.clientWidth >=
209
+ el.scrollWidth - (this.offset.right || 0) - ARRIVED_STATE_THRESHOLD_PIXELS;
210
+
211
+ if (display === "flex" && flexDirection === "row-reverse") {
212
+ this.arrived.left = right;
213
+ this.arrived.right = left;
214
+ } else {
215
+ this.arrived.left = left;
216
+ this.arrived.right = right;
217
+ }
218
+
219
+ this.internalX = scrollLeft;
220
+
221
+ let scrollTop = el.scrollTop;
222
+
223
+ // patch for mobile compatible
224
+ if (this.element === window.document && !scrollTop) scrollTop = window.document.body.scrollTop;
225
+
226
+ this.directions.top = scrollTop < this.internalY;
227
+ this.directions.bottom = scrollTop > this.internalY;
228
+ const top = scrollTop <= (this.offset.top || 0);
229
+ const bottom =
230
+ scrollTop + el.clientHeight >= el.scrollHeight - (this.offset.bottom || 0) - ARRIVED_STATE_THRESHOLD_PIXELS;
231
+
232
+ /**
233
+ * reverse columns and rows behave exactly the other way around,
234
+ * bottom is treated as top and top is treated as the negative version of bottom
235
+ */
236
+ if (display === "flex" && flexDirection === "column-reverse") {
237
+ this.arrived.top = bottom;
238
+ this.arrived.bottom = top;
239
+ } else {
240
+ this.arrived.top = top;
241
+ this.arrived.bottom = bottom;
242
+ }
243
+
244
+ this.internalY = scrollTop;
245
+ };
246
+
247
+ onScrollHandler = (e: Event) => {
248
+ if (!window) return;
249
+
250
+ this.setArrivedState();
251
+
252
+ this.isScrolling = true;
253
+ this.onScrollEndDebounced(e);
254
+ this.onScroll(e);
255
+ };
256
+
257
+ scrollTo(x: number | undefined, y: number | undefined) {
258
+ if (!window) return;
259
+
260
+ (this.element instanceof Document ? window.document.body : this.element)?.scrollTo({
261
+ top: y ?? this.y,
262
+ left: x ?? this.x,
263
+ behavior: this.behavior,
264
+ });
265
+ const scrollContainer =
266
+ (this.element as Window)?.document?.documentElement ||
267
+ (this.element as Document)?.documentElement ||
268
+ (this.element as Element);
269
+ if (x != null) this.internalX = scrollContainer.scrollLeft;
270
+ if (y != null) this.internalY = scrollContainer.scrollTop;
271
+ }
272
+
273
+ scrollToTop() {
274
+ this.scrollTo(undefined, 0);
275
+ }
276
+
277
+ scrollToBottom() {
278
+ if (!window) return;
279
+
280
+ const scrollContainer =
281
+ (this.element as Window)?.document?.documentElement ||
282
+ (this.element as Document)?.documentElement ||
283
+ (this.element as Element);
284
+ this.scrollTo(undefined, scrollContainer.scrollHeight);
285
+ }
286
+
287
+ onScrollEnd = (e: Event) => {
288
+ // dedupe if support native scrollend event
289
+ if (!this.isScrolling) return;
290
+
291
+ this.isScrolling = false;
292
+ this.directions.left = false;
293
+ this.directions.right = false;
294
+ this.directions.top = false;
295
+ this.directions.bottom = false;
296
+ this.onStop(e);
297
+ };
298
+ onScrollEndDebounced = useDebounce(this.onScrollEnd, () => this.idle);
299
+ }
src/lib/spells/textarea-autosize.svelte.ts CHANGED
@@ -1,7 +1,7 @@
1
  import type { Getter } from "melt";
2
  import { extract } from "./extract.svelte.js";
3
  import { useResizeObserver, watch } from "runed";
4
- import { tick } from "svelte";
5
 
6
  export interface TextareaAutosizeOptions {
7
  /** Textarea element to autosize. */
@@ -15,26 +15,37 @@ export interface TextareaAutosizeOptions {
15
  * @default `height`
16
  **/
17
  styleProp?: "height" | "minHeight";
 
 
 
 
 
18
  }
19
 
20
  export class TextareaAutosize {
21
  #options: TextareaAutosizeOptions;
 
 
 
22
  element = $derived.by(() => extract(this.#options.element));
23
  input = $derived.by(() => extract(this.#options.input));
24
  styleProp = $derived.by(() => extract(this.#options.styleProp, "height"));
25
-
26
- textareaScrollHeight = $state(1);
27
  textareaOldWidth = $state(0);
28
 
29
  constructor(options: TextareaAutosizeOptions) {
30
  this.#options = options;
31
 
 
 
 
32
  watch([() => this.input, () => this.element], () => {
33
  tick().then(() => this.triggerResize());
34
  });
35
 
36
  watch(
37
- () => this.textareaScrollHeight,
38
  () => options?.onResize?.()
39
  );
40
 
@@ -45,23 +56,106 @@ export class TextareaAutosize {
45
  const { contentRect } = entry;
46
  if (this.textareaOldWidth === contentRect.width) return;
47
 
48
- requestAnimationFrame(() => {
49
- this.textareaOldWidth = contentRect.width;
50
- this.triggerResize();
51
- });
52
  }
53
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  }
55
 
56
  triggerResize = () => {
57
- if (!this.element) return;
 
 
 
 
58
 
59
- let height = "";
 
60
 
61
- this.element.style[this.styleProp] = "1px";
62
- this.textareaScrollHeight = this.element?.scrollHeight;
63
- height = `${this.textareaScrollHeight}px`;
 
 
 
 
 
64
 
65
- this.element.style[this.styleProp] = height;
 
 
 
 
66
  };
67
  }
 
1
  import type { Getter } from "melt";
2
  import { extract } from "./extract.svelte.js";
3
  import { useResizeObserver, watch } from "runed";
4
+ import { onDestroy, tick } from "svelte";
5
 
6
  export interface TextareaAutosizeOptions {
7
  /** Textarea element to autosize. */
 
15
  * @default `height`
16
  **/
17
  styleProp?: "height" | "minHeight";
18
+ /**
19
+ * Maximum height of the textarea before enabling scrolling.
20
+ * @default `undefined` (no maximum)
21
+ */
22
+ maxHeight?: number;
23
  }
24
 
25
  export class TextareaAutosize {
26
  #options: TextareaAutosizeOptions;
27
+ #resizeTimeout: number | null = null;
28
+ #hiddenTextarea: HTMLTextAreaElement | null = null;
29
+
30
  element = $derived.by(() => extract(this.#options.element));
31
  input = $derived.by(() => extract(this.#options.input));
32
  styleProp = $derived.by(() => extract(this.#options.styleProp, "height"));
33
+ maxHeight = $derived.by(() => extract(this.#options.maxHeight, undefined));
34
+ textareaHeight = $state(0);
35
  textareaOldWidth = $state(0);
36
 
37
  constructor(options: TextareaAutosizeOptions) {
38
  this.#options = options;
39
 
40
+ // Create hidden textarea for measurements
41
+ this.#createHiddenTextarea();
42
+
43
  watch([() => this.input, () => this.element], () => {
44
  tick().then(() => this.triggerResize());
45
  });
46
 
47
  watch(
48
+ () => this.textareaHeight,
49
  () => options?.onResize?.()
50
  );
51
 
 
56
  const { contentRect } = entry;
57
  if (this.textareaOldWidth === contentRect.width) return;
58
 
59
+ this.textareaOldWidth = contentRect.width;
60
+ this.triggerResize();
 
 
61
  }
62
  );
63
+
64
+ onDestroy(() => {
65
+ // Clean up
66
+ if (this.#hiddenTextarea) {
67
+ this.#hiddenTextarea.remove();
68
+ this.#hiddenTextarea = null;
69
+ }
70
+
71
+ if (this.#resizeTimeout) {
72
+ window.cancelAnimationFrame(this.#resizeTimeout);
73
+ this.#resizeTimeout = null;
74
+ }
75
+ });
76
+ }
77
+
78
+ #createHiddenTextarea() {
79
+ // Create a hidden textarea that will be used for measurements
80
+ // This avoids layout shifts caused by manipulating the actual textarea
81
+ if (typeof window === "undefined") return;
82
+
83
+ this.#hiddenTextarea = document.createElement("textarea");
84
+ const style = this.#hiddenTextarea.style;
85
+
86
+ // Make it invisible but keep same text layout properties
87
+ style.visibility = "hidden";
88
+ style.position = "absolute";
89
+ style.overflow = "hidden";
90
+ style.height = "0";
91
+ style.top = "0";
92
+ style.left = "-9999px";
93
+
94
+ document.body.appendChild(this.#hiddenTextarea);
95
+ }
96
+
97
+ #copyStyles() {
98
+ if (!this.element || !this.#hiddenTextarea) return;
99
+
100
+ const computed = window.getComputedStyle(this.element);
101
+
102
+ // Copy all the styles that affect text layout
103
+ const stylesToCopy = [
104
+ "box-sizing",
105
+ "width",
106
+ "padding-top",
107
+ "padding-right",
108
+ "padding-bottom",
109
+ "padding-left",
110
+ "border-top-width",
111
+ "border-right-width",
112
+ "border-bottom-width",
113
+ "border-left-width",
114
+ "font-family",
115
+ "font-size",
116
+ "font-weight",
117
+ "font-style",
118
+ "letter-spacing",
119
+ "text-indent",
120
+ "text-transform",
121
+ "line-height",
122
+ "word-spacing",
123
+ "word-wrap",
124
+ "word-break",
125
+ "white-space",
126
+ ];
127
+
128
+ stylesToCopy.forEach(style => {
129
+ this.#hiddenTextarea!.style.setProperty(style, computed.getPropertyValue(style));
130
+ });
131
+
132
+ // Ensure the width matches exactly
133
+ this.#hiddenTextarea.style.width = `${this.element.clientWidth}px`;
134
  }
135
 
136
  triggerResize = () => {
137
+ if (!this.element || !this.#hiddenTextarea) return;
138
+
139
+ // Copy current styles and content to hidden textarea
140
+ this.#copyStyles();
141
+ this.#hiddenTextarea.value = this.input || "";
142
 
143
+ // Measure the hidden textarea
144
+ const scrollHeight = this.#hiddenTextarea.scrollHeight;
145
 
146
+ // Apply the height, respecting maxHeight if set
147
+ let newHeight = scrollHeight;
148
+ if (this.maxHeight && newHeight > this.maxHeight) {
149
+ newHeight = this.maxHeight;
150
+ this.element.style.overflowY = "auto";
151
+ } else {
152
+ this.element.style.overflowY = "hidden";
153
+ }
154
 
155
+ // Only update if height actually changed
156
+ if (this.textareaHeight !== newHeight) {
157
+ this.textareaHeight = newHeight;
158
+ this.element.style[this.styleProp] = `${newHeight}px`;
159
+ }
160
  };
161
  }
src/lib/types.ts CHANGED
@@ -3,6 +3,7 @@ import type { ChatCompletionInputMessage } from "@huggingface/tasks";
3
 
4
  export type ConversationMessage = Pick<ChatCompletionInputMessage, "name" | "role" | "tool_calls"> & {
5
  content?: string;
 
6
  };
7
 
8
  export type Conversation = {
@@ -169,6 +170,7 @@ export enum LibraryName {
169
 
170
  export enum PipelineTag {
171
  TextGeneration = "text-generation",
 
172
  }
173
 
174
  export type MaybeGetter<T> = T | (() => T);
 
3
 
4
  export type ConversationMessage = Pick<ChatCompletionInputMessage, "name" | "role" | "tool_calls"> & {
5
  content?: string;
6
+ images?: string[];
7
  };
8
 
9
  export type Conversation = {
 
170
 
171
  export enum PipelineTag {
172
  TextGeneration = "text-generation",
173
+ ImageTextToText = "image-text-to-text",
174
  }
175
 
176
  export type MaybeGetter<T> = T | (() => T);
src/lib/utils/file.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function fileToDataURL(file: File): Promise<string> {
2
+ return new Promise((resolve, reject) => {
3
+ const reader = new FileReader();
4
+
5
+ reader.onload = function (event) {
6
+ resolve(event.target?.result as string);
7
+ };
8
+
9
+ reader.onerror = function (error) {
10
+ reject(error);
11
+ };
12
+
13
+ reader.readAsDataURL(file);
14
+ });
15
+ }
src/routes/+layout.svelte CHANGED
@@ -3,6 +3,7 @@
3
  import Prompts from "$lib/components/prompts.svelte";
4
  import QuotaModal from "$lib/components/quota-modal.svelte";
5
  import "../app.css";
 
6
  interface Props {
7
  children?: import("svelte").Snippet;
8
  }
 
3
  import Prompts from "$lib/components/prompts.svelte";
4
  import QuotaModal from "$lib/components/quota-modal.svelte";
5
  import "../app.css";
6
+
7
  interface Props {
8
  children?: import("svelte").Snippet;
9
  }
src/routes/api/models/+server.ts CHANGED
@@ -5,68 +5,117 @@ import { dev } from "$app/environment";
5
 
6
  let cache: ModelWithTokenizer[] | undefined;
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  export const GET: RequestHandler = async ({ fetch }) => {
9
  if (cache?.length && dev) {
10
  console.log("Skipping load, using in memory cache");
11
  return json(cache);
12
  }
13
 
14
- const apiUrl =
15
- "https://huggingface.co/api/models?pipeline_tag=text-generation&filter=conversational&inference_provider=all&limit=100&expand[]=inferenceProviderMapping&expand[]=config&expand[]=library_name&expand[]=pipeline_tag&expand[]=tags&expand[]=mask_token&expand[]=trendingScore";
16
-
17
- const res = await fetch(apiUrl, {
18
- credentials: "include",
19
- headers: {
20
- "Upgrade-Insecure-Requests": "1",
21
- "Sec-Fetch-Dest": "document",
22
- "Sec-Fetch-Mode": "navigate",
23
- "Sec-Fetch-Site": "none",
24
- "Sec-Fetch-User": "?1",
25
- "Priority": "u=0, i",
26
- "Pragma": "no-cache",
27
- "Cache-Control": "no-cache",
28
- },
29
- method: "GET",
30
- mode: "cors",
31
- });
32
-
33
- if (!res.ok) {
34
- console.error(`Error fetching warm models`, res.status, res.statusText);
35
- return json({ models: [] });
36
- }
37
 
38
- const compatibleModels: Model[] = await res.json();
39
- compatibleModels.sort((a, b) => a.id.toLowerCase().localeCompare(b.id.toLowerCase()));
40
-
41
- const promises = compatibleModels.map(async model => {
42
- const configUrl = `https://huggingface.co/${model.id}/raw/main/tokenizer_config.json`;
43
- const res = await fetch(configUrl, {
44
- credentials: "include",
45
- headers: {
46
- "Upgrade-Insecure-Requests": "1",
47
- "Sec-Fetch-Dest": "document",
48
- "Sec-Fetch-Mode": "navigate",
49
- "Sec-Fetch-Site": "none",
50
- "Sec-Fetch-User": "?1",
51
- "Priority": "u=0, i",
52
- "Pragma": "no-cache",
53
- "Cache-Control": "no-cache",
54
- },
55
- method: "GET",
56
- mode: "cors",
57
- });
58
 
59
- if (!res.ok) {
60
- // console.error(`Error fetching tokenizer file for ${model.id}`, res.status, res.statusText);
61
- return null; // Ignore failed requests by returning null
 
 
 
62
  }
63
 
64
- const tokenizerConfig = await res.json();
65
- return { ...model, tokenizerConfig } satisfies ModelWithTokenizer;
66
- });
 
 
 
 
 
 
67
 
68
- const models: ModelWithTokenizer[] = (await Promise.all(promises)).filter(model => model !== null);
69
- cache = models;
 
 
 
 
 
 
 
70
 
71
- return json(cache);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  };
 
5
 
6
  let cache: ModelWithTokenizer[] | undefined;
7
 
8
+ const headers: HeadersInit = {
9
+ "Upgrade-Insecure-Requests": "1",
10
+ "Sec-Fetch-Dest": "document",
11
+ "Sec-Fetch-Mode": "navigate",
12
+ "Sec-Fetch-Site": "none",
13
+ "Sec-Fetch-User": "?1",
14
+ "Priority": "u=0, i",
15
+ "Pragma": "no-cache",
16
+ "Cache-Control": "no-cache",
17
+ };
18
+
19
+ const requestInit: RequestInit = {
20
+ credentials: "include",
21
+ headers,
22
+ method: "GET",
23
+ mode: "cors",
24
+ };
25
+
26
+ interface ApiQueryParams {
27
+ pipeline_tag?: "text-generation" | "image-text-to-text";
28
+ filter: string;
29
+ inference_provider: string;
30
+ limit: number;
31
+ expand: string[];
32
+ }
33
+
34
+ const queryParams: ApiQueryParams = {
35
+ filter: "conversational",
36
+ inference_provider: "all",
37
+ limit: 100,
38
+ expand: ["inferenceProviderMapping", "config", "library_name", "pipeline_tag", "tags", "mask_token", "trendingScore"],
39
+ };
40
+
41
+ const baseUrl = "https://huggingface.co/api/models";
42
+
43
+ function buildApiUrl(params: ApiQueryParams): string {
44
+ const url = new URL(baseUrl);
45
+ // Add simple params
46
+ Object.entries(params).forEach(([key, value]) => {
47
+ if (!Array.isArray(value)) {
48
+ url.searchParams.append(key, String(value));
49
+ }
50
+ });
51
+ // Handle array params specially
52
+ params.expand.forEach(item => {
53
+ url.searchParams.append("expand[]", item);
54
+ });
55
+ return url.toString();
56
+ }
57
+
58
  export const GET: RequestHandler = async ({ fetch }) => {
59
  if (cache?.length && dev) {
60
  console.log("Skipping load, using in memory cache");
61
  return json(cache);
62
  }
63
 
64
+ try {
65
+ // Fetch both types of models in parallel
66
+ const textGenPromise = fetch(buildApiUrl({ ...queryParams, pipeline_tag: "text-generation" }), requestInit);
67
+ const imgText2TextPromise = fetch(buildApiUrl({ ...queryParams, pipeline_tag: "image-text-to-text" }), requestInit);
68
+ const [textGenResponse, imgText2TextResponse] = await Promise.all([textGenPromise, imgText2TextPromise]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
+ if (!textGenResponse.ok) {
71
+ console.error(`Error fetching text-generation models`, textGenResponse.status, textGenResponse.statusText);
72
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
+ if (!imgText2TextResponse.ok) {
75
+ console.error(
76
+ `Error fetching image-text-to-text models`,
77
+ imgText2TextResponse.status,
78
+ imgText2TextResponse.statusText
79
+ );
80
  }
81
 
82
+ // Parse the responses
83
+ const textGenModels: Model[] = textGenResponse.ok ? await textGenResponse.json() : [];
84
+ const imgText2TextModels: Model[] = imgText2TextResponse.ok ? await imgText2TextResponse.json() : [];
85
+
86
+ // Combine the models
87
+ const compatibleModels: Model[] = [...textGenModels, ...imgText2TextModels];
88
+
89
+ // Sort the models
90
+ compatibleModels.sort((a, b) => a.id.toLowerCase().localeCompare(b.id.toLowerCase()));
91
 
92
+ // Fetch tokenizer configs for each model
93
+ const promises = compatibleModels.map(async model => {
94
+ const configUrl = `https://huggingface.co/${model.id}/raw/main/tokenizer_config.json`;
95
+ const res = await fetch(configUrl, {
96
+ credentials: "include",
97
+ headers,
98
+ method: "GET",
99
+ mode: "cors",
100
+ });
101
 
102
+ if (!res.ok) {
103
+ // console.error(`Error fetching tokenizer file for ${model.id}`, res.status, res.statusText);
104
+ return null; // Ignore failed requests by returning null
105
+ }
106
+
107
+ const tokenizerConfig = await res.json();
108
+ return { ...model, tokenizerConfig } satisfies ModelWithTokenizer;
109
+ });
110
+
111
+ const models: ModelWithTokenizer[] = (await Promise.all(promises)).filter(
112
+ (model): model is ModelWithTokenizer => model !== null
113
+ );
114
+ cache = models;
115
+
116
+ return json(cache);
117
+ } catch (error) {
118
+ console.error("Error fetching models:", error);
119
+ return json([]);
120
+ }
121
  };