Thomas G. Lopes commited on
Commit
3a971ea
·
1 Parent(s): 43e26da

combobox wip

Browse files
src/routes/canvas/+page.svelte CHANGED
@@ -5,6 +5,11 @@
5
  import { edges, nodes } from "./state.js";
6
  import type { Node } from "@xyflow/svelte";
7
  import IconAdd from "~icons/lucide/plus";
 
 
 
 
 
8
 
9
  const nodeTypes = { chat: ChatNode } as const;
10
 
 
5
  import { edges, nodes } from "./state.js";
6
  import type { Node } from "@xyflow/svelte";
7
  import IconAdd from "~icons/lucide/plus";
8
+ import { models } from "$lib/state/models.svelte";
9
+ import { projects } from "$lib/state/projects.svelte";
10
+
11
+ await models.load();
12
+ await projects.init();
13
 
14
  const nodeTypes = { chat: ChatNode } as const;
15
 
src/routes/canvas/+page.ts DELETED
@@ -1,17 +0,0 @@
1
- import type { PageLoad } from "./$types.js";
2
- import type { ApiModelsResponse } from "../api/models/+server.js";
3
-
4
- export const load: PageLoad = async ({ fetch }) => {
5
- const [modelsRes, routerRes] = await Promise.all([
6
- fetch("/api/models"),
7
- fetch("https://router.huggingface.co/v1/models"),
8
- ]);
9
-
10
- const models: ApiModelsResponse = await modelsRes.json();
11
- const routerData = await routerRes.json();
12
-
13
- return {
14
- ...models,
15
- routerData,
16
- };
17
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/routes/canvas/chat-node.svelte CHANGED
@@ -11,6 +11,7 @@
11
  import IconAdd from "~icons/lucide/plus";
12
  import IconX from "~icons/lucide/x";
13
  import type { ChatCompletionInputMessage } from "@huggingface/tasks";
 
14
 
15
  type Props = Omit<NodeProps, "data"> & { data: { query: string; response: string; modelId?: Model["id"] } };
16
  let { id, data }: Props = $props();
@@ -104,17 +105,7 @@
104
  >
105
  <!-- Model selector -->
106
  <div class="mb-4">
107
- <label class="mb-1.5 block text-xs font-medium text-gray-600"> Model </label>
108
- <select
109
- class="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm
110
- text-gray-900 transition-colors focus:border-gray-900 focus:ring-2 focus:ring-gray-900/10
111
- focus:outline-none"
112
- bind:value={() => data.modelId, modelId => updateNodeData(id, { modelId })}
113
- >
114
- {#each models.all as model}
115
- <option value={model.id}>{model.id}</option>
116
- {/each}
117
- </select>
118
  </div>
119
 
120
  <form class="flex flex-col gap-4" onsubmit={handleSubmit}>
 
11
  import IconAdd from "~icons/lucide/plus";
12
  import IconX from "~icons/lucide/x";
13
  import type { ChatCompletionInputMessage } from "@huggingface/tasks";
14
+ import ModelPicker from "./model-picker.svelte";
15
 
16
  type Props = Omit<NodeProps, "data"> & { data: { query: string; response: string; modelId?: Model["id"] } };
17
  let { id, data }: Props = $props();
 
105
  >
106
  <!-- Model selector -->
107
  <div class="mb-4">
108
+ <ModelPicker modelId={data.modelId} onModelSelect={modelId => updateNodeData(id, { modelId })} />
 
 
 
 
 
 
 
 
 
 
109
  </div>
110
 
111
  <form class="flex flex-col gap-4" onsubmit={handleSubmit}>
src/routes/canvas/model-picker.svelte ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { models } from "$lib/state/models.svelte.js";
3
+ import { VirtualScroll } from "$lib/spells/virtual-scroll.svelte.js";
4
+ import type { CustomModel, Model } from "$lib/types.js";
5
+ import fuzzysearch from "$lib/utils/search.js";
6
+ import { Combobox } from "melt/builders";
7
+ import { onMount, untrack } from "svelte";
8
+ import typia from "typia";
9
+ import IconCube from "~icons/carbon/cube";
10
+ import IconStar from "~icons/carbon/star";
11
+ import IconEye from "~icons/carbon/view";
12
+ import Tooltip from "../../lib/components/tooltip.svelte";
13
+
14
+ interface Props {
15
+ modelId?: string;
16
+ onModelSelect?: (modelId: string) => void;
17
+ }
18
+
19
+ let { modelId, onModelSelect }: Props = $props();
20
+
21
+ let query = $state("");
22
+
23
+ const selectedModel = $derived.by(() => {
24
+ if (!modelId) return undefined;
25
+ return models.all.find(m => m.id === modelId);
26
+ });
27
+
28
+ // Set initial query to current model ID
29
+ onMount(() => {
30
+ if (!selectedModel) return;
31
+ query = selectedModel.id;
32
+ });
33
+
34
+ const queryIfTouched = $derived.by(() => {
35
+ if (combobox.touched) return query;
36
+ return "";
37
+ });
38
+ const trending = $derived(fuzzysearch({ needle: queryIfTouched, haystack: models.trending, property: "id" }));
39
+ const other = $derived(fuzzysearch({ needle: queryIfTouched, haystack: models.nonTrending, property: "id" }));
40
+ const custom = $derived(fuzzysearch({ needle: queryIfTouched, haystack: models.custom, property: "id" }));
41
+
42
+ // Combine all filtered models into sections for virtualization
43
+ type SectionItem =
44
+ | { type: "header"; content: string }
45
+ | { type: "model"; content: Model | CustomModel; trending?: boolean };
46
+
47
+ const allFilteredModels = $derived.by((): SectionItem[] => {
48
+ const sections: SectionItem[] = [];
49
+
50
+ if (trending.length > 0) {
51
+ sections.push({ type: "header", content: "Trending" });
52
+ trending.forEach(model => sections.push({ type: "model", content: model, trending: true }));
53
+ }
54
+
55
+ if (custom.length > 0) {
56
+ sections.push({ type: "header", content: "Custom endpoints" });
57
+ custom.forEach(model => sections.push({ type: "model", content: model }));
58
+ }
59
+
60
+ if (other.length > 0) {
61
+ sections.push({ type: "header", content: "Other models" });
62
+ other.forEach(model => sections.push({ type: "model", content: model }));
63
+ }
64
+
65
+ return sections;
66
+ });
67
+
68
+ const virtualScroll = new VirtualScroll({
69
+ itemHeight: 30, // Approximate height of each item
70
+ overscan: 5,
71
+ totalItems: () => allFilteredModels.length,
72
+ });
73
+
74
+ const isCustom = typia.createIs<CustomModel>();
75
+
76
+ const combobox = new Combobox<string | undefined>({
77
+ floatingConfig: {
78
+ computePosition: { placement: "bottom-start" },
79
+ },
80
+ sameWidth: true,
81
+ value: () => modelId,
82
+ onValueChange(newModelId) {
83
+ if (!newModelId) return;
84
+ onModelSelect?.(newModelId);
85
+ // Update query to match selected model
86
+ const selectedModel = models.all.find(m => m.id === newModelId);
87
+ if (selectedModel) {
88
+ query = selectedModel.id;
89
+ }
90
+ },
91
+ onNavigate(current, direction) {
92
+ const modelItems = allFilteredModels.filter(item => item.type === "model");
93
+ const currIdx = modelItems.findIndex(item => typeof item.content === "object" && item.content.id === current);
94
+
95
+ let nextIdx: number;
96
+ if (direction === "next") {
97
+ nextIdx = currIdx === -1 ? 0 : (currIdx + 1) % modelItems.length;
98
+ } else {
99
+ nextIdx = currIdx === -1 ? modelItems.length - 1 : (currIdx - 1 + modelItems.length) % modelItems.length;
100
+ }
101
+
102
+ const nextItem = modelItems[nextIdx];
103
+ if (!nextItem) return null;
104
+
105
+ // Scroll to the item
106
+ const allItems = allFilteredModels;
107
+ const actualIdx = allItems.findIndex(item => item === nextItem);
108
+ if (actualIdx !== -1) {
109
+ virtualScroll.scrollToIndex(actualIdx);
110
+ }
111
+
112
+ // Return the content for highlighting
113
+ return typeof nextItem.content === "object" ? nextItem.content.id : null;
114
+ },
115
+ });
116
+
117
+ $effect(() => {
118
+ if (modelId && selectedModel && combobox.open) {
119
+ untrack(() => combobox.highlight(selectedModel.id));
120
+ }
121
+ });
122
+ </script>
123
+
124
+ <div class="relative w-full">
125
+ <label class="mb-1.5 block text-xs font-medium text-gray-600">Model</label>
126
+
127
+ <!-- Combobox input -->
128
+ <input
129
+ class="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm
130
+ text-gray-900 placeholder-gray-500 transition-colors focus:border-gray-900 focus:ring-2
131
+ focus:ring-gray-900/10 focus:outline-none"
132
+ placeholder="Search and select a model..."
133
+ bind:value={query}
134
+ {...combobox.input}
135
+ />
136
+
137
+ <!-- Combobox content -->
138
+ <div
139
+ class="absolute z-50 mt-1 hidden max-h-80 w-full overflow-hidden rounded-lg border
140
+ border-gray-200 bg-white shadow-lg data-[open]:block"
141
+ {...combobox.content}
142
+ popover={undefined}
143
+ >
144
+ <!-- Virtualized model list -->
145
+ <div class="max-h-80 overflow-y-auto" {...virtualScroll.container}>
146
+ {#snippet modelEntry(model: Model | CustomModel, trending?: boolean)}
147
+ {@const [nameSpace, modelName] = model.id.split("/")}
148
+ <div
149
+ class="flex w-full cursor-pointer items-center px-3 py-2 text-sm
150
+ hover:bg-gray-50 data-[highlighted]:bg-gray-100"
151
+ {...combobox.getOption(model.id)}
152
+ >
153
+ {#if trending}
154
+ <div class="mr-1.5 size-4 text-yellow-400">
155
+ <IconStar />
156
+ </div>
157
+ {/if}
158
+
159
+ {#if modelName}
160
+ <span class="inline-flex items-center">
161
+ <span class="text-gray-500">{nameSpace}</span>
162
+ <span class="mx-1 text-gray-300">/</span>
163
+ <span class="text-black">{modelName}</span>
164
+ </span>
165
+ {:else}
166
+ <span class="text-black">{nameSpace}</span>
167
+ {/if}
168
+
169
+ {#if "pipeline_tag" in model && model.pipeline_tag === "image-text-to-text"}
170
+ <Tooltip openDelay={100}>
171
+ {#snippet trigger(tooltip)}
172
+ <div
173
+ class="ml-2 grid size-5 place-items-center rounded bg-gray-500/10 text-gray-500"
174
+ {...tooltip.trigger}
175
+ >
176
+ <IconEye class="size-3.5" />
177
+ </div>
178
+ {/snippet}
179
+ Image text-to-text
180
+ </Tooltip>
181
+ {/if}
182
+
183
+ {#if isCustom(model)}
184
+ <Tooltip openDelay={100}>
185
+ {#snippet trigger(tooltip)}
186
+ <div
187
+ class="ml-2 grid size-5 place-items-center rounded bg-gray-500/10 text-gray-500"
188
+ {...tooltip.trigger}
189
+ >
190
+ <IconCube class="size-3.5" />
191
+ </div>
192
+ {/snippet}
193
+ Custom Model
194
+ </Tooltip>
195
+ {/if}
196
+ </div>
197
+ {/snippet}
198
+
199
+ <!-- Virtual scroll container -->
200
+ <div style="height: {virtualScroll.totalHeight}px; position: relative;">
201
+ <div style="transform: translateY({virtualScroll.offsetY}px);">
202
+ {#each virtualScroll.getVisibleItems(allFilteredModels) as { item }}
203
+ {#if item.type === "header"}
204
+ <div class="bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-500">{item.content}</div>
205
+ {:else}
206
+ {@render modelEntry(item.content, item.trending)}
207
+ {/if}
208
+ {/each}
209
+ </div>
210
+ </div>
211
+ </div>
212
+ </div>
213
+ </div>