11import * as React from 'react' ;
2+ import { useVirtualizer } from '@tanstack/react-virtual' ;
23
3- import { Alert , Box , Button , Card , CircularProgress , IconButton , LinearProgress , List , ListItem , Switch , Typography } from '@mui/joy' ;
4+ import { Alert , Box , Button , Card , Chip , CircularProgress , IconButton , LinearProgress , List , ListItem , Sheet , Switch , Table , Typography } from '@mui/joy' ;
45import CloseRoundedIcon from '@mui/icons-material/CloseRounded' ;
56
67import { ExpanderAccordion } from '~/common/components/ExpanderAccordion' ;
@@ -123,6 +124,7 @@ export function LocalAIAdmin(props: { access: OpenAIAccessSchema, onClose: () =>
123124 // state
124125 const [ installModels , setInstallModels ] = React . useState < { galleryName : string ; modelName : string ; } [ ] > ( [ ] ) ;
125126 const [ showVoiceModels , setShowVoiceModels ] = React . useState ( false ) ;
127+ const parentRef = React . useRef < HTMLDivElement > ( null ) ;
126128
127129 // external state
128130 const { data, error } = apiQuery . llmOpenAI . dialectLocalAI_galleryModelsAvailable . useQuery ( { access : props . access } , {
@@ -131,6 +133,18 @@ export function LocalAIAdmin(props: { access: OpenAIAccessSchema, onClose: () =>
131133
132134 // derived state
133135 const galleryNotConfigured = data === null ;
136+ const filteredModels = React . useMemo ( ( ) =>
137+ data ?. filter ( model => showVoiceModels || ! model . name ?. startsWith ( 'voice-' ) ) || [ ] ,
138+ [ data , showVoiceModels ]
139+ ) ;
140+
141+ // virtualizer
142+ const virtualizer = useVirtualizer ( {
143+ count : filteredModels . length ,
144+ getScrollElement : ( ) => parentRef . current ,
145+ estimateSize : ( ) => 40 , // Fixed row height
146+ overscan : 5 ,
147+ } ) ;
134148
135149
136150 const handleAppendInstall = React . useCallback ( ( galleryName : string , modelName : string ) => {
@@ -179,47 +193,101 @@ export function LocalAIAdmin(props: { access: OpenAIAccessSchema, onClose: () =>
179193 how to configure model galleries.
180194 </ > } /> }
181195
182- { /* List loading */ }
196+ { /* Table loading */ }
183197 { ! data ? (
184198 < CircularProgress color = 'success' />
185199 ) : (
186- < List
187- variant = 'outlined'
188- sx = { {
189- '--ListItem-minHeight' : '2.75rem' ,
190- borderRadius : 'md' ,
191- p : 0 ,
192- } }
193- >
194- { data
195- . filter ( model => showVoiceModels || ! model . name ?. startsWith ( 'voice-' ) )
196- . map ( ( model ) => ! ! model . name && (
197- < ListItem key = { model . name } >
198-
199- { capitalizeFirstLetter ( model . name ) }
200-
201- < Button
202- color = 'neutral'
203- size = 'sm'
204- disabled = { installModels . some ( p => p . galleryName === model . gallery . name && p . modelName === model . name ) }
205- onClick = { ( ) => model . name && handleAppendInstall ( model . gallery . name , model . name ) }
206- sx = { {
207- ml : 'auto' ,
208- } }
209- >
210- Install
211- </ Button >
212- </ ListItem >
213- ) ) }
214-
215- < ListItemSwitch title = 'Show Voice Models' checked = { showVoiceModels } onChange = { setShowVoiceModels } />
216-
217- < ExpanderAccordion title = 'Debug: show JSON' startCollapsed sx = { { fontSize : 'sm' } } >
218- < Box sx = { { whiteSpace : 'break-spaces' } } >
219- { JSON . stringify ( data , null , 2 ) }
200+ < >
201+ < Sheet
202+ variant = 'outlined'
203+ sx = { {
204+ borderRadius : 'md' ,
205+ overflow : 'hidden' ,
206+ } }
207+ >
208+ < Box
209+ ref = { parentRef }
210+ sx = { {
211+ height : '500px' ,
212+ overflow : 'auto' ,
213+ } }
214+ >
215+ < Table
216+ stickyHeader
217+ hoverRow
218+ >
219+ < thead >
220+ < tr >
221+ < th style = { { width : '35%' } } > Model</ th >
222+ < th style = { { width : '35%' } } > Description</ th >
223+ < th style = { { width : '20%' } } > Tags</ th >
224+ < th > Action</ th >
225+ </ tr >
226+ </ thead >
227+ < tbody style = { { height : `${ virtualizer . getTotalSize ( ) } px` , position : 'relative' } } >
228+ { virtualizer . getVirtualItems ( ) . map ( ( virtualItem ) => {
229+ const model = filteredModels [ virtualItem . index ] ;
230+ if ( ! model ?. name ) return null ;
231+
232+ return (
233+ < tr
234+ key = { virtualItem . key }
235+ style = { {
236+ position : 'absolute' ,
237+ top : 0 ,
238+ left : 0 ,
239+ width : '100%' ,
240+ height : '40px' ,
241+ transform : `translateY(${ virtualItem . start } px)` ,
242+ display : 'flex' ,
243+ } }
244+ >
245+ < td style = { { width : '35%' , minWidth : 0 } } className = 'agi-ellipsize' >
246+ { capitalizeFirstLetter ( model . name ) }
247+ </ td >
248+ < td style = { { width : '35%' , minWidth : 0 } } className = 'agi-ellipsize' >
249+ < small > { model . description || '-' } </ small >
250+ </ td >
251+ < td style = { { width : '20%' , minWidth : 0 } } className = 'agi-ellipsize' >
252+ { model . tags && model . tags . length > 0 &&
253+ model . tags . slice ( 0 , 2 ) . map ( ( tag , idx ) => (
254+ < Chip key = { idx } size = 'sm' variant = 'soft' sx = { { fontSize : 'xs' } } >
255+ { tag }
256+ </ Chip >
257+ ) ) }
258+ { model . tags && model . tags . length > 2 && (
259+ < small > +{ model . tags . length - 2 } </ small >
260+ ) }
261+ </ td >
262+ < td style = { { minWidth : 0 , textAlign : 'right' , flexShrink : 0 } } >
263+ < Button
264+ size = 'sm'
265+ color = 'neutral'
266+ disabled = { installModels . some ( p => p . galleryName === model . gallery . name && p . modelName === model . name ) }
267+ onClick = { ( ) => model . name && handleAppendInstall ( model . gallery . name , model . name ) }
268+ sx = { { minWidth : 'auto' , minHeight : 'auto' , px : 1 , py : 0.25 } }
269+ >
270+ Install
271+ </ Button >
272+ </ td >
273+ </ tr >
274+ ) ;
275+ } ) }
276+ </ tbody >
277+ </ Table >
220278 </ Box >
221- </ ExpanderAccordion >
222- </ List >
279+ </ Sheet >
280+
281+ < List variant = 'outlined' sx = { { borderRadius : 'md' , p : 0 , mt : 2 } } >
282+ < ListItemSwitch title = 'Show Voice Models' checked = { showVoiceModels } onChange = { setShowVoiceModels } />
283+
284+ < ExpanderAccordion title = 'Debug: show JSON' startCollapsed sx = { { fontSize : 'sm' } } >
285+ < Box sx = { { whiteSpace : 'break-spaces' } } >
286+ { JSON . stringify ( data , null , 2 ) }
287+ </ Box >
288+ </ ExpanderAccordion >
289+ </ List >
290+ </ >
223291 ) }
224292
225293 </ Box >
0 commit comments