KevIsDev commited on
Commit
0e2e183
·
unverified ·
2 Parent(s): 0fd039b e717d25

Merge pull request #1322 from kamilfurtak/model-search

Browse files
Files changed (1) hide show
  1. app/components/chat/ModelSelector.tsx +229 -23
app/components/chat/ModelSelector.tsx CHANGED
@@ -1,6 +1,9 @@
1
  import type { ProviderInfo } from '~/types/model';
2
- import { useEffect } from 'react';
 
3
  import type { ModelInfo } from '~/lib/modules/llm/types';
 
 
4
 
5
  interface ModelSelectorProps {
6
  model?: string;
@@ -22,12 +25,118 @@ export const ModelSelector = ({
22
  providerList,
23
  modelLoading,
24
  }: ModelSelectorProps) => {
25
- // Load enabled providers from cookies
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  // Update enabled providers when cookies change
28
  useEffect(() => {
29
  // If current provider is disabled, switch to first enabled provider
30
- if (providerList.length == 0) {
31
  return;
32
  }
33
 
@@ -80,27 +189,124 @@ export const ModelSelector = ({
80
  </option>
81
  ))}
82
  </select>
83
- <select
84
- key={provider?.name}
85
- value={model}
86
- onChange={(e) => setModel?.(e.target.value)}
87
- className="flex-1 p-2 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-prompt-background text-bolt-elements-textPrimary focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus transition-all lg:max-w-[70%]"
88
- disabled={modelLoading === 'all' || modelLoading === provider?.name}
89
- >
90
- {modelLoading == 'all' || modelLoading == provider?.name ? (
91
- <option key={0} value="">
92
- Loading...
93
- </option>
94
- ) : (
95
- [...modelList]
96
- .filter((e) => e.provider == provider?.name && e.name)
97
- .map((modelOption, index) => (
98
- <option key={index} value={modelOption.name}>
99
- {modelOption.label}
100
- </option>
101
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  )}
103
- </select>
104
  </div>
105
  );
106
  };
 
1
  import type { ProviderInfo } from '~/types/model';
2
+ import { useEffect, useState, useRef } from 'react';
3
+ import type { KeyboardEvent } from 'react';
4
  import type { ModelInfo } from '~/lib/modules/llm/types';
5
+ import { classNames } from '~/utils/classNames';
6
+ import * as React from 'react';
7
 
8
  interface ModelSelectorProps {
9
  model?: string;
 
25
  providerList,
26
  modelLoading,
27
  }: ModelSelectorProps) => {
28
+ const [modelSearchQuery, setModelSearchQuery] = useState('');
29
+ const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
30
+ const [focusedIndex, setFocusedIndex] = useState(-1);
31
+ const searchInputRef = useRef<HTMLInputElement>(null);
32
+ const optionsRef = useRef<(HTMLDivElement | null)[]>([]);
33
+ const dropdownRef = useRef<HTMLDivElement>(null);
34
+
35
+ useEffect(() => {
36
+ const handleClickOutside = (event: MouseEvent) => {
37
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
38
+ setIsModelDropdownOpen(false);
39
+ setModelSearchQuery('');
40
+ }
41
+ };
42
+
43
+ document.addEventListener('mousedown', handleClickOutside);
44
+
45
+ return () => document.removeEventListener('mousedown', handleClickOutside);
46
+ }, []);
47
+
48
+ // Filter models based on search query
49
+ const filteredModels = [...modelList]
50
+ .filter((e) => e.provider === provider?.name && e.name)
51
+ .filter(
52
+ (model) =>
53
+ model.label.toLowerCase().includes(modelSearchQuery.toLowerCase()) ||
54
+ model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()),
55
+ );
56
+
57
+ // Reset focused index when search query changes or dropdown opens/closes
58
+ useEffect(() => {
59
+ setFocusedIndex(-1);
60
+ }, [modelSearchQuery, isModelDropdownOpen]);
61
+
62
+ // Focus search input when dropdown opens
63
+ useEffect(() => {
64
+ if (isModelDropdownOpen && searchInputRef.current) {
65
+ searchInputRef.current.focus();
66
+ }
67
+ }, [isModelDropdownOpen]);
68
+
69
+ // Handle keyboard navigation
70
+ const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
71
+ if (!isModelDropdownOpen) {
72
+ return;
73
+ }
74
+
75
+ switch (e.key) {
76
+ case 'ArrowDown':
77
+ e.preventDefault();
78
+ setFocusedIndex((prev) => {
79
+ const next = prev + 1;
80
+
81
+ if (next >= filteredModels.length) {
82
+ return 0;
83
+ }
84
+
85
+ return next;
86
+ });
87
+ break;
88
+
89
+ case 'ArrowUp':
90
+ e.preventDefault();
91
+ setFocusedIndex((prev) => {
92
+ const next = prev - 1;
93
+
94
+ if (next < 0) {
95
+ return filteredModels.length - 1;
96
+ }
97
+
98
+ return next;
99
+ });
100
+ break;
101
+
102
+ case 'Enter':
103
+ e.preventDefault();
104
+
105
+ if (focusedIndex >= 0 && focusedIndex < filteredModels.length) {
106
+ const selectedModel = filteredModels[focusedIndex];
107
+ setModel?.(selectedModel.name);
108
+ setIsModelDropdownOpen(false);
109
+ setModelSearchQuery('');
110
+ }
111
+
112
+ break;
113
+
114
+ case 'Escape':
115
+ e.preventDefault();
116
+ setIsModelDropdownOpen(false);
117
+ setModelSearchQuery('');
118
+ break;
119
+
120
+ case 'Tab':
121
+ if (!e.shiftKey && focusedIndex === filteredModels.length - 1) {
122
+ setIsModelDropdownOpen(false);
123
+ }
124
+
125
+ break;
126
+ }
127
+ };
128
+
129
+ // Focus the selected option
130
+ useEffect(() => {
131
+ if (focusedIndex >= 0 && optionsRef.current[focusedIndex]) {
132
+ optionsRef.current[focusedIndex]?.scrollIntoView({ block: 'nearest' });
133
+ }
134
+ }, [focusedIndex]);
135
 
136
  // Update enabled providers when cookies change
137
  useEffect(() => {
138
  // If current provider is disabled, switch to first enabled provider
139
+ if (providerList.length === 0) {
140
  return;
141
  }
142
 
 
189
  </option>
190
  ))}
191
  </select>
192
+
193
+ <div className="relative flex-1 lg:max-w-[70%]" onKeyDown={handleKeyDown} ref={dropdownRef}>
194
+ <div
195
+ className={classNames(
196
+ 'w-full p-2 rounded-lg border border-bolt-elements-borderColor',
197
+ 'bg-bolt-elements-prompt-background text-bolt-elements-textPrimary',
198
+ 'focus-within:outline-none focus-within:ring-2 focus-within:ring-bolt-elements-focus',
199
+ 'transition-all cursor-pointer',
200
+ isModelDropdownOpen ? 'ring-2 ring-bolt-elements-focus' : undefined,
201
+ )}
202
+ onClick={() => setIsModelDropdownOpen(!isModelDropdownOpen)}
203
+ onKeyDown={(e) => {
204
+ if (e.key === 'Enter' || e.key === ' ') {
205
+ e.preventDefault();
206
+ setIsModelDropdownOpen(!isModelDropdownOpen);
207
+ }
208
+ }}
209
+ role="combobox"
210
+ aria-expanded={isModelDropdownOpen}
211
+ aria-controls="model-listbox"
212
+ aria-haspopup="listbox"
213
+ tabIndex={0}
214
+ >
215
+ <div className="flex items-center justify-between">
216
+ <div className="truncate">{modelList.find((m) => m.name === model)?.label || 'Select model'}</div>
217
+ <div
218
+ className={classNames(
219
+ 'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary opacity-75',
220
+ isModelDropdownOpen ? 'rotate-180' : undefined,
221
+ )}
222
+ />
223
+ </div>
224
+ </div>
225
+
226
+ {isModelDropdownOpen && (
227
+ <div
228
+ className="absolute z-10 w-full mt-1 py-1 rounded-lg border border-bolt-elements-borderColor bg-bolt-elements-background-depth-2 shadow-lg"
229
+ role="listbox"
230
+ id="model-listbox"
231
+ >
232
+ <div className="px-2 pb-2">
233
+ <div className="relative">
234
+ <input
235
+ ref={searchInputRef}
236
+ type="text"
237
+ value={modelSearchQuery}
238
+ onChange={(e) => setModelSearchQuery(e.target.value)}
239
+ placeholder="Search models..."
240
+ className={classNames(
241
+ 'w-full pl-8 pr-3 py-1.5 rounded-md text-sm',
242
+ 'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
243
+ 'text-bolt-elements-textPrimary placeholder:text-bolt-elements-textTertiary',
244
+ 'focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus',
245
+ 'transition-all',
246
+ )}
247
+ onClick={(e) => e.stopPropagation()}
248
+ role="searchbox"
249
+ aria-label="Search models"
250
+ />
251
+ <div className="absolute left-2.5 top-1/2 -translate-y-1/2">
252
+ <span className="i-ph:magnifying-glass text-bolt-elements-textTertiary" />
253
+ </div>
254
+ </div>
255
+ </div>
256
+
257
+ <div
258
+ className={classNames(
259
+ 'max-h-60 overflow-y-auto',
260
+ 'sm:scrollbar-none',
261
+ '[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2',
262
+ '[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor',
263
+ '[&::-webkit-scrollbar-thumb]:hover:bg-bolt-elements-borderColorHover',
264
+ '[&::-webkit-scrollbar-thumb]:rounded-full',
265
+ '[&::-webkit-scrollbar-track]:bg-bolt-elements-background-depth-2',
266
+ '[&::-webkit-scrollbar-track]:rounded-full',
267
+ 'sm:[&::-webkit-scrollbar]:w-1.5 sm:[&::-webkit-scrollbar]:h-1.5',
268
+ 'sm:hover:[&::-webkit-scrollbar-thumb]:bg-bolt-elements-borderColor/50',
269
+ 'sm:hover:[&::-webkit-scrollbar-thumb:hover]:bg-bolt-elements-borderColor',
270
+ 'sm:[&::-webkit-scrollbar-track]:bg-transparent',
271
+ )}
272
+ >
273
+ {modelLoading === 'all' || modelLoading === provider?.name ? (
274
+ <div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">Loading...</div>
275
+ ) : filteredModels.length === 0 ? (
276
+ <div className="px-3 py-2 text-sm text-bolt-elements-textTertiary">No models found</div>
277
+ ) : (
278
+ filteredModels.map((modelOption, index) => (
279
+ <div
280
+ ref={(el) => (optionsRef.current[index] = el)}
281
+ key={index}
282
+ role="option"
283
+ aria-selected={model === modelOption.name}
284
+ className={classNames(
285
+ 'px-3 py-2 text-sm cursor-pointer',
286
+ 'hover:bg-bolt-elements-background-depth-3',
287
+ 'text-bolt-elements-textPrimary',
288
+ 'outline-none',
289
+ model === modelOption.name || focusedIndex === index
290
+ ? 'bg-bolt-elements-background-depth-2'
291
+ : undefined,
292
+ focusedIndex === index ? 'ring-1 ring-inset ring-bolt-elements-focus' : undefined,
293
+ )}
294
+ onClick={(e) => {
295
+ e.stopPropagation();
296
+ setModel?.(modelOption.name);
297
+ setIsModelDropdownOpen(false);
298
+ setModelSearchQuery('');
299
+ }}
300
+ tabIndex={focusedIndex === index ? 0 : -1}
301
+ >
302
+ {modelOption.label}
303
+ </div>
304
+ ))
305
+ )}
306
+ </div>
307
+ </div>
308
  )}
309
+ </div>
310
  </div>
311
  );
312
  };