Skip to content

Commit 8d3037f

Browse files
richiejplocalai-bot
authored andcommitted
feat(ui, gallery): Show model backends and add searchable model/backend selector (mudler#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>
1 parent f486d85 commit 8d3037f

14 files changed

Lines changed: 606 additions & 69 deletions

File tree

core/gallery/backend_resolve.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package gallery
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"sync"
8+
"time"
9+
10+
"github.com/mudler/LocalAI/pkg/downloader"
11+
"github.com/mudler/LocalAI/pkg/xsync"
12+
"github.com/mudler/xlog"
13+
"gopkg.in/yaml.v3"
14+
)
15+
16+
// modelConfigCacheEntry holds a cached parsed config_file map from a URL-referenced model config.
17+
type modelConfigCacheEntry struct {
18+
configMap map[string]interface{}
19+
lastUpdated time.Time
20+
}
21+
22+
func (e modelConfigCacheEntry) hasExpired() bool {
23+
return e.lastUpdated.Before(time.Now().Add(-1 * time.Hour))
24+
}
25+
26+
// modelConfigCache caches parsed model config maps keyed by URL.
27+
var modelConfigCache = xsync.NewSyncedMap[string, modelConfigCacheEntry]()
28+
29+
// resolveBackend determines the backend for a GalleryModel by checking (in priority order):
30+
// 1. Overrides["backend"] — highest priority, same as install-time merge
31+
// 2. Inline ConfigFile["backend"] — for models with inline config maps
32+
// 3. URL-referenced config file — fetched, parsed, and cached
33+
//
34+
// The model's URL should already be resolved (local override applied) before calling this.
35+
func resolveBackend(m *GalleryModel, basePath string) string {
36+
// 1. Overrides take priority (matches install-time mergo.WithOverride behavior)
37+
if b, ok := m.Overrides["backend"].(string); ok && b != "" {
38+
return b
39+
}
40+
41+
// 2. Inline config_file map
42+
if b, ok := m.ConfigFile["backend"].(string); ok && b != "" {
43+
return b
44+
}
45+
46+
// 3. Fetch and parse the URL-referenced config
47+
if m.URL != "" {
48+
configMap := fetchModelConfigMap(m.URL, basePath)
49+
if b, ok := configMap["backend"].(string); ok && b != "" {
50+
return b
51+
}
52+
}
53+
54+
return ""
55+
}
56+
57+
// fetchModelConfigMap fetches a model config URL, parses the config_file YAML string
58+
// inside it, and returns the result as a map. Results are cached for 1 hour.
59+
// Local file:// URLs skip the cache so edits are picked up immediately.
60+
func fetchModelConfigMap(modelURL, basePath string) map[string]interface{} {
61+
// Check cache (skip for file:// URLs so local edits are picked up immediately)
62+
isLocal := strings.HasPrefix(modelURL, downloader.LocalPrefix)
63+
if !isLocal && modelConfigCache.Exists(modelURL) {
64+
entry := modelConfigCache.Get(modelURL)
65+
if !entry.hasExpired() {
66+
return entry.configMap
67+
}
68+
modelConfigCache.Delete(modelURL)
69+
}
70+
71+
// Reuse existing gallery config fetcher
72+
modelConfig, err := GetGalleryConfigFromURL[ModelConfig](modelURL, basePath)
73+
if err != nil {
74+
xlog.Debug("Failed to fetch model config for backend resolution", "url", modelURL, "error", err)
75+
// Cache the failure for remote URLs to avoid repeated fetch attempts
76+
if !isLocal {
77+
modelConfigCache.Set(modelURL, modelConfigCacheEntry{
78+
configMap: map[string]interface{}{},
79+
lastUpdated: time.Now(),
80+
})
81+
}
82+
return map[string]interface{}{}
83+
}
84+
85+
// Parse the config_file YAML string into a map
86+
configMap := make(map[string]interface{})
87+
if modelConfig.ConfigFile != "" {
88+
if err := yaml.Unmarshal([]byte(modelConfig.ConfigFile), &configMap); err != nil {
89+
xlog.Debug("Failed to parse config_file for backend resolution", "url", modelURL, "error", err)
90+
}
91+
}
92+
93+
// Cache for remote URLs
94+
if !isLocal {
95+
modelConfigCache.Set(modelURL, modelConfigCacheEntry{
96+
configMap: configMap,
97+
lastUpdated: time.Now(),
98+
})
99+
}
100+
101+
return configMap
102+
}
103+
104+
// prefetchModelConfigs fetches model config URLs in parallel to warm the cache.
105+
// This avoids sequential HTTP requests on cold start (~50 unique gallery files).
106+
func prefetchModelConfigs(urls []string, basePath string) {
107+
const maxConcurrency = 10
108+
sem := make(chan struct{}, maxConcurrency)
109+
var wg sync.WaitGroup
110+
for _, url := range urls {
111+
wg.Add(1)
112+
go func(u string) {
113+
defer wg.Done()
114+
sem <- struct{}{}
115+
defer func() { <-sem }()
116+
fetchModelConfigMap(u, basePath)
117+
}(url)
118+
}
119+
wg.Wait()
120+
}
121+
122+
// resolveModelURLLocally attempts to resolve a github: model URL to a local file://
123+
// path when the gallery itself was loaded from a local path. This supports development
124+
// workflows where new model files are added locally before being pushed to GitHub.
125+
//
126+
// For example, if the gallery was loaded from file:///path/to/gallery/index.yaml
127+
// and a model references github:mudler/LocalAI/gallery/foo.yaml@master, this will
128+
// check if /path/to/gallery/foo.yaml exists locally and return file:///path/to/gallery/foo.yaml.
129+
//
130+
// This is applied to model.URL in AvailableGalleryModels so that both listing (backend
131+
// resolution) and installation use the same resolved URL.
132+
func resolveModelURLLocally(modelURL, galleryURL string) string {
133+
galleryDir := localGalleryDir(galleryURL)
134+
if galleryDir == "" {
135+
return modelURL
136+
}
137+
138+
// Only handle github: URLs
139+
if !strings.HasPrefix(modelURL, downloader.GithubURI) && !strings.HasPrefix(modelURL, downloader.GithubURI2) {
140+
return modelURL
141+
}
142+
143+
// Extract the filename from the github URL
144+
// Format: github:org/repo/path/to/file.yaml@branch
145+
raw := strings.TrimPrefix(modelURL, downloader.GithubURI2)
146+
raw = strings.TrimPrefix(raw, downloader.GithubURI)
147+
// Remove @branch suffix
148+
if idx := strings.LastIndex(raw, "@"); idx >= 0 {
149+
raw = raw[:idx]
150+
}
151+
filename := filepath.Base(raw)
152+
153+
localPath := filepath.Join(galleryDir, filename)
154+
if _, err := os.Stat(localPath); err == nil {
155+
return downloader.LocalPrefix + localPath
156+
}
157+
158+
return modelURL
159+
}
160+
161+
// localGalleryDir returns the directory of a gallery URL if it's local, or "" if remote.
162+
func localGalleryDir(galleryURL string) string {
163+
if strings.HasPrefix(galleryURL, downloader.LocalPrefix) {
164+
return filepath.Dir(strings.TrimPrefix(galleryURL, downloader.LocalPrefix))
165+
}
166+
// Plain path (no scheme) that exists on disk
167+
if !strings.Contains(galleryURL, "://") && !strings.HasPrefix(galleryURL, downloader.GithubURI) {
168+
if info, err := os.Stat(galleryURL); err == nil && !info.IsDir() {
169+
return filepath.Dir(galleryURL)
170+
}
171+
}
172+
return ""
173+
}

core/gallery/gallery.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,36 @@ func AvailableGalleryModels(galleries []config.Gallery, systemState *system.Syst
218218
if err != nil {
219219
return nil, err
220220
}
221+
222+
// Resolve model URLs locally (for local galleries) and collect unique
223+
// URLs that need fetching for backend resolution.
224+
uniqueURLs := map[string]struct{}{}
225+
for _, m := range galleryModels {
226+
if m.URL != "" {
227+
m.URL = resolveModelURLLocally(m.URL, gallery.URL)
228+
}
229+
if m.Backend == "" && m.URL != "" {
230+
uniqueURLs[m.URL] = struct{}{}
231+
}
232+
}
233+
234+
// Pre-warm cache with parallel fetches to avoid sequential HTTP
235+
// requests on cold start (~50 unique gallery config files).
236+
if len(uniqueURLs) > 0 {
237+
urls := make([]string, 0, len(uniqueURLs))
238+
for u := range uniqueURLs {
239+
urls = append(urls, u)
240+
}
241+
prefetchModelConfigs(urls, systemState.Model.ModelsPath)
242+
}
243+
244+
// Resolve backends from warm cache.
245+
for _, m := range galleryModels {
246+
if m.Backend == "" {
247+
m.Backend = resolveBackend(m, systemState.Model.ModelsPath)
248+
}
249+
}
250+
221251
models = append(models, galleryModels...)
222252
}
223253

core/gallery/metadata_type.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,7 @@ type Metadata struct {
1919
Gallery config.Gallery `json:"gallery,omitempty" yaml:"gallery,omitempty"`
2020
// Installed is used to indicate if the model is installed or not
2121
Installed bool `json:"installed,omitempty" yaml:"installed,omitempty"`
22+
// Backend is the resolved backend engine for this model (e.g. "llama-cpp").
23+
// Populated at load time from overrides, inline config, or the URL-referenced config file.
24+
Backend string `json:"backend,omitempty" yaml:"backend,omitempty"`
2225
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
const MOCK_MODELS_RESPONSE = {
4+
models: [
5+
{ name: 'llama-model', description: 'A llama model', backend: 'llama-cpp', installed: false, tags: ['llm'] },
6+
{ name: 'whisper-model', description: 'A whisper model', backend: 'whisper', installed: true, tags: ['stt'] },
7+
{ name: 'stablediffusion-model', description: 'An image model', backend: 'stablediffusion', installed: false, tags: ['sd'] },
8+
{ name: 'unknown-model', description: 'No backend', backend: '', installed: false, tags: [] },
9+
],
10+
allBackends: ['llama-cpp', 'stablediffusion', 'whisper'],
11+
allTags: ['llm', 'sd', 'stt'],
12+
availableModels: 4,
13+
installedModels: 1,
14+
totalPages: 1,
15+
currentPage: 1,
16+
}
17+
18+
test.describe('Models Gallery - Backend Features', () => {
19+
test.beforeEach(async ({ page }) => {
20+
await page.route('**/api/models*', (route) => {
21+
route.fulfill({
22+
contentType: 'application/json',
23+
body: JSON.stringify(MOCK_MODELS_RESPONSE),
24+
})
25+
})
26+
await page.goto('/app/models')
27+
// Wait for the table to render
28+
await expect(page.locator('th', { hasText: 'Backend' })).toBeVisible({ timeout: 10_000 })
29+
})
30+
31+
test('backend column header is visible', async ({ page }) => {
32+
await expect(page.locator('th', { hasText: 'Backend' })).toBeVisible()
33+
})
34+
35+
test('backend badges shown in table rows', async ({ page }) => {
36+
const table = page.locator('table')
37+
await expect(table.locator('.badge', { hasText: 'llama-cpp' })).toBeVisible()
38+
await expect(table.locator('.badge', { hasText: /^whisper$/ })).toBeVisible()
39+
})
40+
41+
test('backend dropdown is visible', async ({ page }) => {
42+
await expect(page.locator('button', { hasText: 'All Backends' })).toBeVisible()
43+
})
44+
45+
test('clicking backend dropdown opens searchable panel', async ({ page }) => {
46+
await page.locator('button', { hasText: 'All Backends' }).click()
47+
await expect(page.locator('input[placeholder="Search backends..."]')).toBeVisible()
48+
})
49+
50+
test('typing in search filters dropdown options', async ({ page }) => {
51+
await page.locator('button', { hasText: 'All Backends' }).click()
52+
const searchInput = page.locator('input[placeholder="Search backends..."]')
53+
await searchInput.fill('llama')
54+
55+
// llama-cpp option should be visible, whisper should not
56+
const dropdown = page.locator('input[placeholder="Search backends..."]').locator('..') .locator('..')
57+
await expect(dropdown.locator('text=llama-cpp')).toBeVisible()
58+
await expect(dropdown.locator('text=whisper')).not.toBeVisible()
59+
})
60+
61+
test('selecting a backend updates the dropdown label', async ({ page }) => {
62+
await page.locator('button', { hasText: 'All Backends' }).click()
63+
// Click the llama-cpp option within the dropdown (not the table badge)
64+
const dropdown = page.locator('input[placeholder="Search backends..."]').locator('..').locator('..')
65+
await dropdown.locator('text=llama-cpp').click()
66+
67+
// The dropdown button should now show the selected backend instead of "All Backends"
68+
await expect(page.locator('button span', { hasText: 'llama-cpp' })).toBeVisible()
69+
})
70+
71+
test('expanded row shows backend in detail', async ({ page }) => {
72+
// Click the first model row to expand it
73+
await page.locator('tr', { hasText: 'llama-model' }).click()
74+
75+
// The detail view should show Backend label and value
76+
const detail = page.locator('td[colspan="8"]')
77+
await expect(detail.locator('text=Backend')).toBeVisible()
78+
await expect(detail.locator('text=llama-cpp')).toBeVisible()
79+
})
80+
})

core/http/react-ui/playwright.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export default defineConfig({
1616
},
1717
],
1818
webServer: process.env.PLAYWRIGHT_EXTERNAL_SERVER ? undefined : {
19-
command: '../../../tests/e2e-ui/ui-test-server --mock-backend=../../../tests/e2e/mock-backend/mock-backend --port=8089',
19+
command: '../../../tests/e2e-ui/ui-test-server --mock-backend=../../../tests/e2e/mock-backend/mock-backend --port=8089 > /tmp/ui-test-server.log 2>&1',
2020
port: 8089,
2121
timeout: 120_000,
2222
reuseExistingServer: !process.env.CI,

core/http/react-ui/src/App.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1828,7 +1828,7 @@
18281828
.chat-mcp-dropdown-menu {
18291829
position: absolute;
18301830
top: calc(100% + 4px);
1831-
right: 0;
1831+
left: 0;
18321832
z-index: 100;
18331833
min-width: 240px;
18341834
max-height: 320px;
Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,38 @@
1-
import { useEffect } from 'react'
1+
import { useEffect, useMemo } from 'react'
22
import { useModels } from '../hooks/useModels'
3+
import SearchableSelect from './SearchableSelect'
34

4-
export default function ModelSelector({ value, onChange, capability, className = '' }) {
5-
const { models, loading } = useModels(capability)
5+
export default function ModelSelector({
6+
value, onChange, capability, className = '',
7+
options: externalOptions, loading: externalLoading,
8+
disabled: externalDisabled, searchPlaceholder, style,
9+
}) {
10+
// Skip capability fetch when external options are provided (capability will be undefined)
11+
const { models: hookModels, loading: hookLoading } = useModels(externalOptions ? undefined : capability)
12+
13+
const modelNames = useMemo(
14+
() => externalOptions || hookModels.map(m => m.id),
15+
[externalOptions, hookModels]
16+
)
17+
const isLoading = externalOptions ? (externalLoading || false) : hookLoading
18+
const isDisabled = isLoading || (externalDisabled || false)
619

720
useEffect(() => {
8-
if (!value && models.length > 0) {
9-
onChange(models[0].id)
21+
if (modelNames.length > 0 && (!value || !modelNames.includes(value))) {
22+
onChange(modelNames[0])
1023
}
11-
}, [models, value, onChange])
24+
}, [modelNames, value, onChange])
1225

1326
return (
14-
<select
15-
className={`model-selector ${className}`}
16-
value={value}
17-
onChange={(e) => onChange(e.target.value)}
18-
disabled={loading}
19-
>
20-
{loading && <option>Loading models...</option>}
21-
{!loading && models.length === 0 && <option>No models available</option>}
22-
{models.map(model => (
23-
<option key={model.id} value={model.id}>{model.id}</option>
24-
))}
25-
</select>
27+
<SearchableSelect
28+
value={value || ''}
29+
onChange={onChange}
30+
options={modelNames}
31+
placeholder={isLoading ? 'Loading models...' : (modelNames.length === 0 ? 'No models available' : 'Select model...')}
32+
searchPlaceholder={searchPlaceholder || 'Search models...'}
33+
disabled={isDisabled}
34+
className={className}
35+
style={style}
36+
/>
2637
)
2738
}

0 commit comments

Comments
 (0)