feat(ui, gallery): Show model backends and add searchable model/backend selector (#9060)

* feat(ui, gallery): Display and filter by the backend models use

Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(ui): Add searchable model backend/model selector and prevent delete models being selected

Signed-off-by: Richard Palethorpe <io@richiejp.com>

---------

Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
Richard Palethorpe
2026-03-18 20:14:41 +00:00
committed by GitHub
parent e832efeb9e
commit cfb7641eea
14 changed files with 606 additions and 69 deletions

View File

@@ -0,0 +1,173 @@
package gallery
import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/xsync"
"github.com/mudler/xlog"
"gopkg.in/yaml.v3"
)
// modelConfigCacheEntry holds a cached parsed config_file map from a URL-referenced model config.
type modelConfigCacheEntry struct {
configMap map[string]interface{}
lastUpdated time.Time
}
func (e modelConfigCacheEntry) hasExpired() bool {
return e.lastUpdated.Before(time.Now().Add(-1 * time.Hour))
}
// modelConfigCache caches parsed model config maps keyed by URL.
var modelConfigCache = xsync.NewSyncedMap[string, modelConfigCacheEntry]()
// resolveBackend determines the backend for a GalleryModel by checking (in priority order):
// 1. Overrides["backend"] — highest priority, same as install-time merge
// 2. Inline ConfigFile["backend"] — for models with inline config maps
// 3. URL-referenced config file — fetched, parsed, and cached
//
// The model's URL should already be resolved (local override applied) before calling this.
func resolveBackend(m *GalleryModel, basePath string) string {
// 1. Overrides take priority (matches install-time mergo.WithOverride behavior)
if b, ok := m.Overrides["backend"].(string); ok && b != "" {
return b
}
// 2. Inline config_file map
if b, ok := m.ConfigFile["backend"].(string); ok && b != "" {
return b
}
// 3. Fetch and parse the URL-referenced config
if m.URL != "" {
configMap := fetchModelConfigMap(m.URL, basePath)
if b, ok := configMap["backend"].(string); ok && b != "" {
return b
}
}
return ""
}
// fetchModelConfigMap fetches a model config URL, parses the config_file YAML string
// inside it, and returns the result as a map. Results are cached for 1 hour.
// Local file:// URLs skip the cache so edits are picked up immediately.
func fetchModelConfigMap(modelURL, basePath string) map[string]interface{} {
// Check cache (skip for file:// URLs so local edits are picked up immediately)
isLocal := strings.HasPrefix(modelURL, downloader.LocalPrefix)
if !isLocal && modelConfigCache.Exists(modelURL) {
entry := modelConfigCache.Get(modelURL)
if !entry.hasExpired() {
return entry.configMap
}
modelConfigCache.Delete(modelURL)
}
// Reuse existing gallery config fetcher
modelConfig, err := GetGalleryConfigFromURL[ModelConfig](modelURL, basePath)
if err != nil {
xlog.Debug("Failed to fetch model config for backend resolution", "url", modelURL, "error", err)
// Cache the failure for remote URLs to avoid repeated fetch attempts
if !isLocal {
modelConfigCache.Set(modelURL, modelConfigCacheEntry{
configMap: map[string]interface{}{},
lastUpdated: time.Now(),
})
}
return map[string]interface{}{}
}
// Parse the config_file YAML string into a map
configMap := make(map[string]interface{})
if modelConfig.ConfigFile != "" {
if err := yaml.Unmarshal([]byte(modelConfig.ConfigFile), &configMap); err != nil {
xlog.Debug("Failed to parse config_file for backend resolution", "url", modelURL, "error", err)
}
}
// Cache for remote URLs
if !isLocal {
modelConfigCache.Set(modelURL, modelConfigCacheEntry{
configMap: configMap,
lastUpdated: time.Now(),
})
}
return configMap
}
// prefetchModelConfigs fetches model config URLs in parallel to warm the cache.
// This avoids sequential HTTP requests on cold start (~50 unique gallery files).
func prefetchModelConfigs(urls []string, basePath string) {
const maxConcurrency = 10
sem := make(chan struct{}, maxConcurrency)
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
fetchModelConfigMap(u, basePath)
}(url)
}
wg.Wait()
}
// resolveModelURLLocally attempts to resolve a github: model URL to a local file://
// path when the gallery itself was loaded from a local path. This supports development
// workflows where new model files are added locally before being pushed to GitHub.
//
// For example, if the gallery was loaded from file:///path/to/gallery/index.yaml
// and a model references github:mudler/LocalAI/gallery/foo.yaml@master, this will
// check if /path/to/gallery/foo.yaml exists locally and return file:///path/to/gallery/foo.yaml.
//
// This is applied to model.URL in AvailableGalleryModels so that both listing (backend
// resolution) and installation use the same resolved URL.
func resolveModelURLLocally(modelURL, galleryURL string) string {
galleryDir := localGalleryDir(galleryURL)
if galleryDir == "" {
return modelURL
}
// Only handle github: URLs
if !strings.HasPrefix(modelURL, downloader.GithubURI) && !strings.HasPrefix(modelURL, downloader.GithubURI2) {
return modelURL
}
// Extract the filename from the github URL
// Format: github:org/repo/path/to/file.yaml@branch
raw := strings.TrimPrefix(modelURL, downloader.GithubURI2)
raw = strings.TrimPrefix(raw, downloader.GithubURI)
// Remove @branch suffix
if idx := strings.LastIndex(raw, "@"); idx >= 0 {
raw = raw[:idx]
}
filename := filepath.Base(raw)
localPath := filepath.Join(galleryDir, filename)
if _, err := os.Stat(localPath); err == nil {
return downloader.LocalPrefix + localPath
}
return modelURL
}
// localGalleryDir returns the directory of a gallery URL if it's local, or "" if remote.
func localGalleryDir(galleryURL string) string {
if strings.HasPrefix(galleryURL, downloader.LocalPrefix) {
return filepath.Dir(strings.TrimPrefix(galleryURL, downloader.LocalPrefix))
}
// Plain path (no scheme) that exists on disk
if !strings.Contains(galleryURL, "://") && !strings.HasPrefix(galleryURL, downloader.GithubURI) {
if info, err := os.Stat(galleryURL); err == nil && !info.IsDir() {
return filepath.Dir(galleryURL)
}
}
return ""
}

View File

@@ -218,6 +218,36 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst
if err != nil {
return nil, err
}
// Resolve model URLs locally (for local galleries) and collect unique
// URLs that need fetching for backend resolution.
uniqueURLs := map[string]struct{}{}
for _, m := range galleryModels {
if m.URL != "" {
m.URL = resolveModelURLLocally(m.URL, gallery.URL)
}
if m.Backend == "" && m.URL != "" {
uniqueURLs[m.URL] = struct{}{}
}
}
// Pre-warm cache with parallel fetches to avoid sequential HTTP
// requests on cold start (~50 unique gallery config files).
if len(uniqueURLs) > 0 {
urls := make([]string, 0, len(uniqueURLs))
for u := range uniqueURLs {
urls = append(urls, u)
}
prefetchModelConfigs(urls, systemState.Model.ModelsPath)
}
// Resolve backends from warm cache.
for _, m := range galleryModels {
if m.Backend == "" {
m.Backend = resolveBackend(m, systemState.Model.ModelsPath)
}
}
models = append(models, galleryModels...)
}

View File

@@ -19,4 +19,7 @@ type Metadata struct {
Gallery config.Gallery `json:"gallery,omitempty" yaml:"gallery,omitempty"`
// Installed is used to indicate if the model is installed or not
Installed bool `json:"installed,omitempty" yaml:"installed,omitempty"`
// Backend is the resolved backend engine for this model (e.g. "llama-cpp").
// Populated at load time from overrides, inline config, or the URL-referenced config file.
Backend string `json:"backend,omitempty" yaml:"backend,omitempty"`
}

View File

@@ -0,0 +1,80 @@
import { test, expect } from '@playwright/test'
const MOCK_MODELS_RESPONSE = {
models: [
{ name: 'llama-model', description: 'A llama model', backend: 'llama-cpp', installed: false, tags: ['llm'] },
{ name: 'whisper-model', description: 'A whisper model', backend: 'whisper', installed: true, tags: ['stt'] },
{ name: 'stablediffusion-model', description: 'An image model', backend: 'stablediffusion', installed: false, tags: ['sd'] },
{ name: 'unknown-model', description: 'No backend', backend: '', installed: false, tags: [] },
],
allBackends: ['llama-cpp', 'stablediffusion', 'whisper'],
allTags: ['llm', 'sd', 'stt'],
availableModels: 4,
installedModels: 1,
totalPages: 1,
currentPage: 1,
}
test.describe('Models Gallery - Backend Features', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/models*', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify(MOCK_MODELS_RESPONSE),
})
})
await page.goto('/app/models')
// Wait for the table to render
await expect(page.locator('th', { hasText: 'Backend' })).toBeVisible({ timeout: 10_000 })
})
test('backend column header is visible', async ({ page }) => {
await expect(page.locator('th', { hasText: 'Backend' })).toBeVisible()
})
test('backend badges shown in table rows', async ({ page }) => {
const table = page.locator('table')
await expect(table.locator('.badge', { hasText: 'llama-cpp' })).toBeVisible()
await expect(table.locator('.badge', { hasText: /^whisper$/ })).toBeVisible()
})
test('backend dropdown is visible', async ({ page }) => {
await expect(page.locator('button', { hasText: 'All Backends' })).toBeVisible()
})
test('clicking backend dropdown opens searchable panel', async ({ page }) => {
await page.locator('button', { hasText: 'All Backends' }).click()
await expect(page.locator('input[placeholder="Search backends..."]')).toBeVisible()
})
test('typing in search filters dropdown options', async ({ page }) => {
await page.locator('button', { hasText: 'All Backends' }).click()
const searchInput = page.locator('input[placeholder="Search backends..."]')
await searchInput.fill('llama')
// llama-cpp option should be visible, whisper should not
const dropdown = page.locator('input[placeholder="Search backends..."]').locator('..') .locator('..')
await expect(dropdown.locator('text=llama-cpp')).toBeVisible()
await expect(dropdown.locator('text=whisper')).not.toBeVisible()
})
test('selecting a backend updates the dropdown label', async ({ page }) => {
await page.locator('button', { hasText: 'All Backends' }).click()
// Click the llama-cpp option within the dropdown (not the table badge)
const dropdown = page.locator('input[placeholder="Search backends..."]').locator('..').locator('..')
await dropdown.locator('text=llama-cpp').click()
// The dropdown button should now show the selected backend instead of "All Backends"
await expect(page.locator('button span', { hasText: 'llama-cpp' })).toBeVisible()
})
test('expanded row shows backend in detail', async ({ page }) => {
// Click the first model row to expand it
await page.locator('tr', { hasText: 'llama-model' }).click()
// The detail view should show Backend label and value
const detail = page.locator('td[colspan="8"]')
await expect(detail.locator('text=Backend')).toBeVisible()
await expect(detail.locator('text=llama-cpp')).toBeVisible()
})
})

View File

@@ -16,7 +16,7 @@ export default defineConfig({
},
],
webServer: process.env.PLAYWRIGHT_EXTERNAL_SERVER ? undefined : {
command: '../../../tests/e2e-ui/ui-test-server --mock-backend=../../../tests/e2e/mock-backend/mock-backend --port=8089',
command: '../../../tests/e2e-ui/ui-test-server --mock-backend=../../../tests/e2e/mock-backend/mock-backend --port=8089 > /tmp/ui-test-server.log 2>&1',
port: 8089,
timeout: 120_000,
reuseExistingServer: !process.env.CI,

View File

@@ -1828,7 +1828,7 @@
.chat-mcp-dropdown-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
left: 0;
z-index: 100;
min-width: 240px;
max-height: 320px;

View File

@@ -1,27 +1,38 @@
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { useModels } from '../hooks/useModels'
import SearchableSelect from './SearchableSelect'
export default function ModelSelector({ value, onChange, capability, className = '' }) {
const { models, loading } = useModels(capability)
export default function ModelSelector({
value, onChange, capability, className = '',
options: externalOptions, loading: externalLoading,
disabled: externalDisabled, searchPlaceholder, style,
}) {
// Skip capability fetch when external options are provided (capability will be undefined)
const { models: hookModels, loading: hookLoading } = useModels(externalOptions ? undefined : capability)
const modelNames = useMemo(
() => externalOptions || hookModels.map(m => m.id),
[externalOptions, hookModels]
)
const isLoading = externalOptions ? (externalLoading || false) : hookLoading
const isDisabled = isLoading || (externalDisabled || false)
useEffect(() => {
if (!value && models.length > 0) {
onChange(models[0].id)
if (modelNames.length > 0 && (!value || !modelNames.includes(value))) {
onChange(modelNames[0])
}
}, [models, value, onChange])
}, [modelNames, value, onChange])
return (
<select
className={`model-selector ${className}`}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={loading}
>
{loading && <option>Loading models...</option>}
{!loading && models.length === 0 && <option>No models available</option>}
{models.map(model => (
<option key={model.id} value={model.id}>{model.id}</option>
))}
</select>
<SearchableSelect
value={value || ''}
onChange={onChange}
options={modelNames}
placeholder={isLoading ? 'Loading models...' : (modelNames.length === 0 ? 'No models available' : 'Select model...')}
searchPlaceholder={searchPlaceholder || 'Search models...'}
disabled={isDisabled}
className={className}
style={style}
/>
)
}

View File

@@ -0,0 +1,170 @@
import { useState, useEffect, useRef, useMemo } from 'react'
export default function SearchableSelect({
value, onChange, options, placeholder = 'Select...',
allOption, searchPlaceholder = 'Search...',
disabled = false, style, className = '',
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [focusIndex, setFocusIndex] = useState(-1)
const ref = useRef(null)
const buttonRef = useRef(null)
const listRef = useRef(null)
const items = useMemo(() =>
options.map(o => typeof o === 'string' ? { value: o, label: o } : o),
[options]
)
useEffect(() => {
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const filtered = query
? items.filter(o => o.label.toLowerCase().includes(query.toLowerCase()))
: items
// Determine which item Enter will select
const enterTarget = focusIndex >= 0
? { type: 'item', index: focusIndex }
: filtered.length > 0
? { type: 'item', index: 0 }
: allOption
? { type: 'all' }
: null
const select = (val) => {
onChange(val)
setOpen(false)
setQuery('')
setFocusIndex(-1)
buttonRef.current?.focus()
}
const handleKeyDown = (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setFocusIndex(i => Math.min(i + 1, filtered.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setFocusIndex(i => Math.max(i - 1, -1))
} else if (e.key === 'Enter') {
e.preventDefault()
if (!enterTarget) return
if (enterTarget.type === 'all') {
select('')
} else {
select(filtered[enterTarget.index].value)
}
} else if (e.key === 'Escape') {
setOpen(false)
setQuery('')
setFocusIndex(-1)
buttonRef.current?.focus()
}
}
// Scroll focused item into view
useEffect(() => {
if (focusIndex >= 0 && listRef.current) {
const offset = allOption ? focusIndex + 1 : focusIndex
const item = listRef.current.children[offset]
if (item) item.scrollIntoView({ block: 'nearest' })
}
}, [focusIndex, allOption])
const displayLabel = !value ? placeholder
: (items.find(o => o.value === value)?.label ?? value)
const itemStyle = (isActive, isFocused) => ({
padding: '6px 10px', fontSize: '0.8125rem', cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: '6px',
color: isActive ? 'var(--color-primary)' : 'var(--color-text-primary)',
fontWeight: isActive ? 600 : 400,
background: isFocused ? 'var(--color-bg-tertiary)' : (isActive ? 'var(--color-bg-tertiary)' : 'transparent'),
})
return (
<div ref={ref} className={className} style={{ position: 'relative', minWidth: 160, ...style }}>
<button
ref={buttonRef}
type="button"
className="input"
onClick={() => { if (!disabled) { setOpen(!open); setQuery(''); setFocusIndex(-1) } }}
style={{
width: '100%', padding: '4px 8px', fontSize: '0.8125rem',
cursor: disabled ? 'not-allowed' : 'pointer',
display: 'flex', alignItems: 'center', gap: '6px',
background: 'var(--color-bg-primary)', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
color: value ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
opacity: disabled ? 0.5 : 1,
}}
>
<span style={{ flex: 1, textAlign: 'left' }}>{displayLabel}</span>
<i className="fas fa-chevron-down" style={{ fontSize: '0.5rem', color: 'var(--color-text-muted)' }} />
</button>
{open && (
<div style={{
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 100, marginTop: 4,
minWidth: 200, maxHeight: 260, background: 'var(--color-bg-secondary)',
border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', display: 'flex', flexDirection: 'column',
}}>
<div style={{ padding: '6px', borderBottom: '1px solid var(--color-border-subtle)' }}>
<input
autoFocus
className="input"
type="text"
placeholder={searchPlaceholder}
value={query}
onChange={(e) => { setQuery(e.target.value); setFocusIndex(-1) }}
onKeyDown={handleKeyDown}
style={{ width: '100%', padding: '4px 8px', fontSize: '0.8125rem' }}
/>
</div>
<div ref={listRef} style={{ overflowY: 'auto', maxHeight: 200 }}>
{allOption && (
<div
onClick={() => select('')}
style={itemStyle(!value, focusIndex === -1 && enterTarget?.type === 'all')}
onMouseEnter={() => setFocusIndex(-1)}
>
<span style={{ flex: 1 }}>{allOption}</span>
{enterTarget?.type === 'all' && (
<span style={{ marginLeft: 'auto', color: 'var(--color-text-muted)', fontSize: '0.75rem' }}></span>
)}
</div>
)}
{filtered.map((o, i) => {
const isActive = value === o.value
const isEnterTarget = enterTarget?.type === 'item' && enterTarget.index === i
const isFocused = focusIndex === i || isEnterTarget
return (
<div
key={o.value}
onClick={() => select(o.value)}
style={itemStyle(isActive, isFocused)}
onMouseEnter={() => setFocusIndex(i)}
>
<span style={{ flex: 1 }}>{o.label}</span>
{isEnterTarget && (
<span style={{ marginLeft: 'auto', color: 'var(--color-text-muted)', fontSize: '0.75rem' }}></span>
)}
</div>
)
})}
{filtered.length === 0 && !allOption && (
<div style={{ padding: '6px 10px', fontSize: '0.8125rem', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
No matches
</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -837,32 +837,6 @@ export default function Chat() {
<i className={`fas fa-${sidebarOpen ? 'angles-left' : 'angles-right'}`} />
</button>
<span className="chat-header-title">{activeChat.name}</span>
<ModelSelector
value={activeChat.model}
onChange={(model) => updateChatSettings(activeChat.id, { model })}
capability="FLAG_CHAT"
/>
{activeChat.model && (
<>
{modelInfo?.backend && (
<span className="badge badge-info" style={{ fontSize: '0.75rem' }}>{modelInfo.backend}</span>
)}
<button
className="btn btn-secondary btn-sm"
onClick={() => setShowModelInfo(!showModelInfo)}
title="Model info"
>
<i className="fas fa-info-circle" />
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/app/model-editor/${encodeURIComponent(activeChat.model)}`)}
title="Edit model config"
>
<i className="fas fa-edit" />
</button>
</>
)}
<UnifiedMCPDropdown
serverMCPAvailable={mcpAvailable}
mcpServerList={mcpServerList}
@@ -898,7 +872,31 @@ export default function Chat() {
selectedResources={activeChat.mcpResources || []}
onToggleResource={toggleMcpResource}
/>
<ModelSelector
value={activeChat.model}
onChange={(model) => updateChatSettings(activeChat.id, { model })}
capability="FLAG_CHAT"
style={{ flex: '1 1 0', minWidth: 120 }}
/>
<div className="chat-header-actions">
{activeChat.model && (
<>
<button
className="btn btn-secondary btn-sm"
onClick={() => setShowModelInfo(!showModelInfo)}
title="Model info"
>
<i className="fas fa-info-circle" />
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => navigate(`/app/model-editor/${encodeURIComponent(activeChat.model)}`)}
title="Edit model config"
>
<i className="fas fa-edit" />
</button>
</>
)}
<label className="canvas-mode-toggle" title="Extract code blocks and media into a side panel for preview, copy, and download">
<i className="fas fa-columns" />
<span className="canvas-mode-label">Canvas</span>

View File

@@ -3,6 +3,7 @@ import { useNavigate, useOutletContext } from 'react-router-dom'
import { modelsApi } from '../utils/api'
import LoadingSpinner from '../components/LoadingSpinner'
import CodeEditor from '../components/CodeEditor'
import SearchableSelect from '../components/SearchableSelect'
const BACKENDS = [
{ value: '', label: 'Auto-detect (based on URI)' },
@@ -321,9 +322,15 @@ export default function ImportModel() {
<div style={{ display: 'grid', gap: 'var(--spacing-md)' }}>
<div className="form-group" style={{ marginBottom: 0 }}>
<label className="form-label"><i className="fas fa-server" style={{ marginRight: '6px' }} />Backend</label>
<select className="input" value={prefs.backend} onChange={e => updatePref('backend', e.target.value)} disabled={isSubmitting}>
{BACKENDS.map(b => <option key={b.value} value={b.value}>{b.label}</option>)}
</select>
<SearchableSelect
value={prefs.backend}
onChange={(v) => updatePref('backend', v)}
options={BACKENDS.filter(b => b.value !== '')}
allOption="Auto-detect (based on URI)"
placeholder="Auto-detect (based on URI)"
searchPlaceholder="Search backends..."
disabled={isSubmitting}
/>
<p style={hintStyle}>Force a specific backend. Leave empty to auto-detect from URI.</p>
</div>

View File

@@ -3,6 +3,7 @@ import { useNavigate, useOutletContext } from 'react-router-dom'
import { modelsApi } from '../utils/api'
import { useOperations } from '../hooks/useOperations'
import { useResources } from '../hooks/useResources'
import SearchableSelect from '../components/SearchableSelect'
import React from 'react'
@@ -108,6 +109,7 @@ function GalleryLoader() {
)
}
const FILTERS = [
{ key: '', label: 'All', icon: 'fa-layer-group' },
{ key: 'llm', label: 'LLM', icon: 'fa-brain' },
@@ -137,6 +139,8 @@ export default function Models() {
const [expandedRow, setExpandedRow] = useState(null)
const [expandedFiles, setExpandedFiles] = useState(false)
const [stats, setStats] = useState({ total: 0, installed: 0, repositories: 0 })
const [backendFilter, setBackendFilter] = useState('')
const [allBackends, setAllBackends] = useState([])
const debounceRef = useRef(null)
// Total GPU memory for "fits" check
@@ -148,6 +152,7 @@ export default function Models() {
const searchVal = params.search !== undefined ? params.search : search
const filterVal = params.filter !== undefined ? params.filter : filter
const sortVal = params.sort !== undefined ? params.sort : sort
const backendVal = params.backendFilter !== undefined ? params.backendFilter : backendFilter
// Combine search text and filter into 'term' param
const term = searchVal || filterVal || ''
const queryParams = {
@@ -155,6 +160,7 @@ export default function Models() {
items: 9,
}
if (term) queryParams.term = term
if (backendVal) queryParams.backend = backendVal
if (sortVal) {
queryParams.sort = sortVal
queryParams.order = params.order || order
@@ -166,16 +172,17 @@ export default function Models() {
total: data?.availableModels || 0,
installed: data?.installedModels || 0,
})
setAllBackends(data?.allBackends || [])
} catch (err) {
addToast(`Failed to load models: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}, [page, search, filter, sort, order, addToast])
}, [page, search, filter, sort, order, backendFilter, addToast])
useEffect(() => {
fetchModels()
}, [page, filter, sort, order])
}, [page, filter, sort, order, backendFilter])
// Re-fetch when operations change (install/delete completion)
useEffect(() => {
@@ -305,6 +312,17 @@ export default function Models() {
{f.label}
</button>
))}
{allBackends.length > 0 && (
<SearchableSelect
value={backendFilter}
onChange={(v) => { setBackendFilter(v); setPage(1) }}
options={allBackends}
placeholder="All Backends"
allOption="All Backends"
searchPlaceholder="Search backends..."
style={{ marginLeft: 'auto' }}
/>
)}
</div>
{/* Table */}
@@ -328,6 +346,7 @@ export default function Models() {
Model Name {sort === 'name' && <i className={`fas fa-arrow-${order === 'asc' ? 'up' : 'down'}`} style={{ fontSize: '0.625rem' }} />}
</th>
<th>Description</th>
<th>Backend</th>
<th>Size / VRAM</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
Status {sort === 'status' && <i className={`fas fa-arrow-${order === 'asc' ? 'up' : 'down'}`} style={{ fontSize: '0.625rem' }} />}
@@ -393,6 +412,17 @@ export default function Models() {
</div>
</td>
{/* Backend */}
<td>
{model.backend ? (
<span className="badge badge-info" style={{ fontSize: '0.6875rem' }}>
{model.backend}
</span>
) : (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}></span>
)}
</td>
{/* Size / VRAM */}
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
@@ -476,7 +506,7 @@ export default function Models() {
{/* Expanded detail row */}
{isExpanded && (
<tr>
<td colSpan="7" style={{ padding: 0 }}>
<td colSpan="8" style={{ padding: 0 }}>
<ModelDetail model={model} fit={fit} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} />
</td>
</tr>
@@ -539,6 +569,13 @@ function ModelDetail({ model, fit, expandedFiles, setExpandedFiles }) {
</span>
)}
</DetailRow>
<DetailRow label="Backend">
{model.backend && (
<span className="badge badge-info" style={{ fontSize: '0.6875rem' }}>
{model.backend}
</span>
)}
</DetailRow>
<DetailRow label="Size">
{model.estimated_size_display && model.estimated_size_display !== '0 B' ? model.estimated_size_display : null}
</DetailRow>

View File

@@ -1,6 +1,7 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { useOutletContext } from 'react-router-dom'
import { realtimeApi } from '../utils/api'
import ModelSelector from '../components/ModelSelector'
const STATUS_STYLES = {
disconnected: { icon: 'fa-solid fa-circle', color: 'var(--color-text-secondary)', bg: 'transparent' },
@@ -17,6 +18,7 @@ export default function Talk() {
// Pipeline models
const [pipelineModels, setPipelineModels] = useState([])
const pipelineModelNames = useMemo(() => pipelineModels.map(m => m.name), [pipelineModels])
const [selectedModel, setSelectedModel] = useState('')
const [modelsLoading, setModelsLoading] = useState(true)
@@ -482,23 +484,18 @@ export default function Talk() {
<label className="form-label" style={{ fontSize: '0.8125rem' }}>
<i className="fas fa-brain" style={{ color: 'var(--color-primary)', marginRight: 4 }} /> Pipeline Model
</label>
<select
className="model-selector"
<ModelSelector
value={selectedModel}
onChange={(e) => {
setSelectedModel(e.target.value)
const m = pipelineModels.find(p => p.name === e.target.value)
onChange={(v) => {
setSelectedModel(v)
const m = pipelineModels.find(p => p.name === v)
if (m && !voiceEdited) setVoice(m.voice || '')
}}
disabled={modelsLoading || isConnected}
style={{ width: '100%' }}
>
{modelsLoading && <option>Loading models...</option>}
{!modelsLoading && pipelineModels.length === 0 && <option>No pipeline models available</option>}
{pipelineModels.map(m => (
<option key={m.name} value={m.name}>{m.name}</option>
))}
</select>
options={pipelineModelNames}
loading={modelsLoading}
disabled={isConnected}
searchPlaceholder="Search pipeline models..."
/>
</div>
{/* Pipeline details */}

View File

@@ -239,10 +239,35 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
}
sort.Strings(tags)
// Get all available backends (before filtering so dropdown always shows all)
allBackendsMap := map[string]struct{}{}
for _, m := range models {
if b := m.Backend; b != "" {
allBackendsMap[b] = struct{}{}
}
}
backendNames := make([]string, 0, len(allBackendsMap))
for b := range allBackendsMap {
backendNames = append(backendNames, b)
}
sort.Strings(backendNames)
if term != "" {
models = gallery.GalleryElements[*gallery.GalleryModel](models).Search(term)
}
// Filter by backend if requested
backendFilter := c.QueryParam("backend")
if backendFilter != "" {
var filtered gallery.GalleryElements[*gallery.GalleryModel]
for _, m := range models {
if m.Backend == backendFilter {
filtered = append(filtered, m)
}
}
models = filtered
}
// Get model statuses
processingModelsData, taskTypes := opcache.GetStatus()
@@ -359,6 +384,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
"isDeletion": isDeletionOp,
"trustRemoteCode": trustRemoteCodeExists,
"additionalFiles": m.AdditionalFiles,
"backend": m.Backend,
}
if hasWeightFiles(m.AdditionalFiles) {
@@ -449,6 +475,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
"models": modelsJSON,
"repositories": appConfig.Galleries,
"allTags": tags,
"allBackends": backendNames,
"processingModels": processingModelsData,
"taskTypes": taskTypes,
"availableModels": totalModels,

View File

@@ -1,5 +1,5 @@
ARG GO_VERSION=1.25.4
ARG PLAYWRIGHT_VERSION=v1.52.0
ARG PLAYWRIGHT_VERSION=v1.58.2
###################################
# Stage 1: Build React UI
@@ -80,13 +80,17 @@ ENV CI=true
ENV PLAYWRIGHT_EXTERNAL_SERVER=1
CMD ["bash", "-c", "\
ui-test-server --mock-backend=/usr/local/bin/mock-backend --port=8089 & \
ui-test-server --mock-backend=/usr/local/bin/mock-backend --port=8089 > /tmp/ui-test-server.log 2>&1 & \
for i in $(seq 1 30); do \
curl -sf http://127.0.0.1:8089/readyz > /dev/null 2>&1 && break; \
sleep 1; \
done && \
npx playwright test; \
TEST_EXIT=$?; \
if [ $TEST_EXIT -ne 0 ]; then \
echo '--- ui-test-server logs (last 50 lines) ---'; \
tail -50 /tmp/ui-test-server.log; \
fi; \
kill %1 2>/dev/null; \
exit $TEST_EXIT \
"]