Thomas G. Lopes commited on
Commit
7bfbc58
·
1 Parent(s): 73e0644

virtualization wip

Browse files
package.json CHANGED
@@ -52,7 +52,7 @@
52
  "highlight.js": "^11.10.0",
53
  "jiti": "^2.4.2",
54
  "jsdom": "^26.0.0",
55
- "melt": "^0.36.0",
56
  "openai": "^4.90.0",
57
  "playwright": "^1.52.0",
58
  "postcss": "^8.4.38",
 
52
  "highlight.js": "^11.10.0",
53
  "jiti": "^2.4.2",
54
  "jsdom": "^26.0.0",
55
+ "melt": "^0.40.0",
56
  "openai": "^4.90.0",
57
  "playwright": "^1.52.0",
58
  "postcss": "^8.4.38",
pnpm-lock.yaml CHANGED
@@ -136,8 +136,8 @@ importers:
136
  specifier: ^26.0.0
137
  version: 26.1.0
138
  melt:
139
- specifier: ^0.36.0
140
- version: 0.36.0(@floating-ui/[email protected])([email protected])
141
  openai:
142
  specifier: ^4.90.0
143
  version: 4.90.0([email protected])([email protected])
@@ -1833,6 +1833,9 @@ packages:
1833
1834
  resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
1835
 
 
 
 
1836
1837
  resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
1838
 
@@ -2278,8 +2281,8 @@ packages:
2278
  resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
2279
  engines: {node: '>= 0.8'}
2280
 
2281
- melt@0.36.0:
2282
- resolution: {integrity: sha512-lJdUuPvsCZs7zpcL2iSvxerHxv3QuM91FoTbdsliOQ2+J3fR4ADqUN878J4kkQSzzHlWqyedQmEBDP6U3iEWgA==}
2283
  peerDependencies:
2284
  '@floating-ui/dom': ^1.6.0
2285
  svelte: ^5.30.1
@@ -2380,11 +2383,6 @@ packages:
2380
  engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
2381
  hasBin: true
2382
 
2383
2384
- resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
2385
- engines: {node: ^18 || >=20}
2386
- hasBin: true
2387
-
2388
2389
  resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
2390
 
@@ -2990,6 +2988,9 @@ packages:
2990
  resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
2991
  engines: {node: ^14.18.0 || >=16.0.0}
2992
 
 
 
 
2993
2994
  resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==}
2995
 
@@ -4954,6 +4955,10 @@ snapshots:
4954
 
4955
4956
 
 
 
 
 
4957
4958
 
4959
@@ -5394,12 +5399,12 @@ snapshots:
5394
 
5395
5396
 
5397
- melt@0.36.0(@floating-ui/[email protected])([email protected]):
5398
  dependencies:
5399
  '@floating-ui/dom': 1.6.13
5400
  dequal: 2.0.3
 
5401
  jest-axe: 9.0.0
5402
- nanoid: 5.1.5
5403
  runed: 0.23.4([email protected])
5404
  svelte: 5.38.7
5405
 
@@ -5480,8 +5485,6 @@ snapshots:
5480
 
5481
5482
 
5483
5484
-
5485
5486
 
5487
@@ -6120,6 +6123,8 @@ snapshots:
6120
  '@pkgr/core': 0.1.1
6121
  tslib: 2.8.1
6122
 
 
 
6123
6124
 
6125
 
136
  specifier: ^26.0.0
137
  version: 26.1.0
138
  melt:
139
+ specifier: ^0.40.0
140
+ version: 0.40.0(@floating-ui/[email protected])([email protected])
141
  openai:
142
  specifier: ^4.90.0
143
  version: 4.90.0([email protected])([email protected])
 
1833
1834
  resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
1835
 
1836
1837
+ resolution: {integrity: sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==}
1838
+
1839
1840
  resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
1841
 
 
2281
  resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
2282
  engines: {node: '>= 0.8'}
2283
 
2284
+ melt@0.40.0:
2285
+ resolution: {integrity: sha512-G+urf5f2Cy62cXiPQ+nf3muscjIE9e38kjDWZckU4x08bDaP+hryJ9rrsu81V1x0ZAgEwnMAMKVAFrEdomOYWw==}
2286
  peerDependencies:
2287
  '@floating-ui/dom': ^1.6.0
2288
  svelte: ^5.30.1
 
2383
  engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
2384
  hasBin: true
2385
 
 
 
 
 
 
2386
2387
  resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
2388
 
 
2988
  resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
2989
  engines: {node: ^14.18.0 || >=16.0.0}
2990
 
2991
2992
+ resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
2993
+
2994
2995
  resolution: {integrity: sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==}
2996
 
 
4955
 
4956
4957
 
4958
4959
+ dependencies:
4960
+ tabbable: 6.2.0
4961
+
4962
4963
 
4964
 
5399
 
5400
5401
 
5402
+ melt@0.40.0(@floating-ui/[email protected])([email protected]):
5403
  dependencies:
5404
  '@floating-ui/dom': 1.6.13
5405
  dequal: 2.0.3
5406
+ focus-trap: 7.6.5
5407
  jest-axe: 9.0.0
 
5408
  runed: 0.23.4([email protected])
5409
  svelte: 5.38.7
5410
 
 
5485
 
5486
5487
 
 
 
5488
5489
 
5490
 
6123
  '@pkgr/core': 0.1.1
6124
  tslib: 2.8.1
6125
 
6126
6127
+
6128
6129
 
6130
src/lib/components/inference-playground/model-selector-modal.svelte CHANGED
@@ -2,6 +2,7 @@
2
  import { autofocus } from "$lib/attachments/autofocus.js";
3
  import type { ConversationClass } from "$lib/state/conversations.svelte";
4
  import { models } from "$lib/state/models.svelte.js";
 
5
  import type { CustomModel, Model } from "$lib/types.js";
6
  import { noop } from "$lib/utils/noop.js";
7
  import fuzzysearch from "$lib/utils/search.js";
@@ -26,6 +27,56 @@
26
 
27
  let { onModelSelect, onClose, conversation }: Props = $props();
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  const combobox = new Combobox<string | undefined>({
30
  onOpenChange(o) {
31
  if (!o) onClose?.();
@@ -40,6 +91,16 @@
40
  onModelSelect?.(modelId);
41
  onClose?.();
42
  },
 
 
 
 
 
 
 
 
 
 
43
  });
44
  $effect(() => {
45
  untrack(() => combobox.highlight(conversation.model.id));
@@ -48,25 +109,6 @@
48
  combobox.open = true;
49
  });
50
  });
51
-
52
- let backdropEl = $state<HTMLDivElement>();
53
- let query = $state("");
54
-
55
- const trending = $derived(fuzzysearch({ needle: query, haystack: models.trending, property: "id" }));
56
- const other = $derived(fuzzysearch({ needle: query, haystack: models.nonTrending, property: "id" }));
57
- const custom = $derived(fuzzysearch({ needle: query, haystack: models.custom, property: "id" }));
58
-
59
- function handleBackdropClick(event: MouseEvent) {
60
- event.stopPropagation();
61
- if (window?.getSelection()?.toString()) {
62
- return;
63
- }
64
- if (event.target === backdropEl) {
65
- onClose?.();
66
- }
67
- }
68
-
69
- const isCustom = typia.createIs<CustomModel>();
70
  </script>
71
 
72
  <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -91,6 +133,7 @@
91
  class="max-h-[220px] overflow-x-hidden overflow-y-auto md:max-h-[300px]"
92
  {...combobox.content}
93
  popover={undefined}
 
94
  >
95
  {#snippet modelEntry(model: Model | CustomModel, trending?: boolean)}
96
  {@const [nameSpace, modelName] = model.id.split("/")}
@@ -168,38 +211,34 @@
168
  {/if}
169
  </div>
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
- <div
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__", "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
  </div>
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>
 
2
  import { autofocus } from "$lib/attachments/autofocus.js";
3
  import type { ConversationClass } from "$lib/state/conversations.svelte";
4
  import { models } from "$lib/state/models.svelte.js";
5
+ import { VirtualScroll } from "$lib/spells/virtual-scroll.svelte.js";
6
  import type { CustomModel, Model } from "$lib/types.js";
7
  import { noop } from "$lib/utils/noop.js";
8
  import fuzzysearch from "$lib/utils/search.js";
 
27
 
28
  let { onModelSelect, onClose, conversation }: Props = $props();
29
 
30
+ let backdropEl = $state<HTMLDivElement>();
31
+ let query = $state("");
32
+
33
+ const trending = $derived(fuzzysearch({ needle: query, haystack: models.trending, property: "id" }));
34
+ const other = $derived(fuzzysearch({ needle: query, haystack: models.nonTrending, property: "id" }));
35
+ const custom = $derived(fuzzysearch({ needle: query, haystack: models.custom, property: "id" }));
36
+
37
+ // Combine all filtered models into sections for virtualization
38
+ type SectionItem =
39
+ | { type: "header"; content: string }
40
+ | { type: "model"; content: Model | CustomModel | "__custom__"; trending?: boolean };
41
+
42
+ const allFilteredModels = $derived.by((): SectionItem[] => {
43
+ const sections: SectionItem[] = [];
44
+
45
+ if (trending.length > 0) {
46
+ sections.push({ type: "header", content: "Trending" });
47
+ trending.forEach(model => sections.push({ type: "model", content: model, trending: true }));
48
+ }
49
+
50
+ sections.push({ type: "header", content: "Custom endpoints" });
51
+ custom.forEach(model => sections.push({ type: "model", content: model }));
52
+ sections.push({ type: "model", content: "__custom__" }); // Add custom button
53
+
54
+ if (other.length > 0) {
55
+ sections.push({ type: "header", content: "Other models" });
56
+ other.forEach(model => sections.push({ type: "model", content: model }));
57
+ }
58
+
59
+ return sections;
60
+ });
61
+
62
+ const virtualScroll = new VirtualScroll({
63
+ itemHeight: 30, // Approximate height of each item
64
+ overscan: 5,
65
+ totalItems: () => allFilteredModels.length,
66
+ });
67
+
68
+ function handleBackdropClick(event: MouseEvent) {
69
+ event.stopPropagation();
70
+ if (window?.getSelection()?.toString()) {
71
+ return;
72
+ }
73
+ if (event.target === backdropEl) {
74
+ onClose?.();
75
+ }
76
+ }
77
+
78
+ const isCustom = typia.createIs<CustomModel>();
79
+
80
  const combobox = new Combobox<string | undefined>({
81
  onOpenChange(o) {
82
  if (!o) onClose?.();
 
91
  onModelSelect?.(modelId);
92
  onClose?.();
93
  },
94
+ onNavigate(current, direction) {
95
+ const currIdx = allFilteredModels.findIndex(item => item.type === "model" && item.content === current);
96
+ // TODO: get next/prev item, scroll to it, and return its content. Make sure
97
+ // to wrap around.
98
+ if (direction === "next") {
99
+ }
100
+ if (direction === "prev") {
101
+ }
102
+ return null;
103
+ },
104
  });
105
  $effect(() => {
106
  untrack(() => combobox.highlight(conversation.model.id));
 
109
  combobox.open = true;
110
  });
111
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  </script>
113
 
114
  <!-- svelte-ignore a11y_no_static_element_interactions -->
 
133
  class="max-h-[220px] overflow-x-hidden overflow-y-auto md:max-h-[300px]"
134
  {...combobox.content}
135
  popover={undefined}
136
+ {...virtualScroll.container}
137
  >
138
  {#snippet modelEntry(model: Model | CustomModel, trending?: boolean)}
139
  {@const [nameSpace, modelName] = model.id.split("/")}
 
211
  {/if}
212
  </div>
213
  {/snippet}
214
+
215
+ <!-- Virtual scroll container -->
216
+ <div style="height: {virtualScroll.totalHeight}px; position: relative;">
217
+ <div style="transform: translateY({virtualScroll.offsetY}px);">
218
+ {#each virtualScroll.getVisibleItems(allFilteredModels) as { item }}
219
+ {#if item.type === "header"}
220
+ <div class="px-2 py-1.5 text-xs font-medium text-gray-500">{item.content}</div>
221
+ {:else if item.content === "__custom__"}
222
+ <div
223
+ 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"
224
+ {...combobox.getOption("__custom__", "custom", () => {
225
+ onClose?.();
226
+ openCustomModelConfig({
227
+ onSubmit: model => {
228
+ onModelSelect?.(model.id);
229
+ },
230
+ });
231
+ })}
232
+ >
233
+ <IconAdd class="rounded bg-blue-500/10 text-blue-600" />
234
+ Add a custom endpoint
235
+ </div>
236
+ {:else}
237
+ {@render modelEntry(item.content, item.trending)}
238
+ {/if}
239
+ {/each}
240
+ </div>
241
  </div>
 
 
 
 
 
 
242
  </div>
243
  </div>
244
  </div>
src/lib/spells/virtual-scroll.svelte.ts ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MaybeGetter } from "$lib/types.js";
2
+ import { ElementSize } from "runed";
3
+ import { createAttachmentKey } from "svelte/attachments";
4
+ import type { HTMLAttributes } from "svelte/elements";
5
+ import { extract } from "./extract.svelte";
6
+
7
+ interface VirtualScrollOptions {
8
+ totalItems?: MaybeGetter<number>;
9
+ itemHeight: MaybeGetter<number>;
10
+ overscan?: MaybeGetter<number | undefined>;
11
+ }
12
+
13
+ export class VirtualScroll {
14
+ #options: VirtualScrollOptions;
15
+ itemHeight = $derived.by(() => extract(this.#options.itemHeight));
16
+ overscan = $derived.by(() => extract(this.#options.overscan, 10));
17
+ totalItems = $derived.by(() => extract(this.#options.totalItems, 0));
18
+
19
+ #scrollTop = $state(0);
20
+
21
+ #containerEl = $state<HTMLElement>();
22
+ #containerSize = new ElementSize(() => this.#containerEl);
23
+
24
+ constructor(options: VirtualScrollOptions) {
25
+ this.#options = options;
26
+ }
27
+
28
+ get scrollTop() {
29
+ return this.#scrollTop;
30
+ }
31
+
32
+ set scrollTop(value: number) {
33
+ this.#scrollTop = value;
34
+ }
35
+
36
+ get visibleRange() {
37
+ const startIndex = Math.floor(this.#scrollTop / this.itemHeight);
38
+ const endIndex = Math.min(
39
+ startIndex + Math.ceil(this.#containerSize.height / this.itemHeight),
40
+ this.totalItems - 1,
41
+ );
42
+
43
+ return {
44
+ start: Math.max(0, startIndex - this.overscan),
45
+ end: Math.min(this.totalItems - 1, endIndex + this.overscan),
46
+ };
47
+ }
48
+
49
+ get totalHeight() {
50
+ return this.totalItems * this.itemHeight;
51
+ }
52
+
53
+ get offsetY() {
54
+ return this.visibleRange.start * this.itemHeight;
55
+ }
56
+
57
+ getVisibleItems<T>(items: T[]): Array<{ item: T; index: number }> {
58
+ const { start, end } = this.visibleRange;
59
+ return items.slice(start, end + 1).map((item, i) => ({
60
+ item,
61
+ index: start + i,
62
+ }));
63
+ }
64
+
65
+ #attachmentKey = createAttachmentKey();
66
+ get container() {
67
+ return {
68
+ onscroll: e => {
69
+ this.scrollTop = e.currentTarget.scrollTop;
70
+ },
71
+ [this.#attachmentKey]: node => {
72
+ this.#containerEl = node;
73
+ return () => {
74
+ this.#containerEl = undefined;
75
+ };
76
+ },
77
+ } as const satisfies HTMLAttributes<HTMLElement>;
78
+ }
79
+ }