diff --git a/core/gallery/backend_resolve.go b/core/gallery/backend_resolve.go new file mode 100644 index 000000000..64a89c504 --- /dev/null +++ b/core/gallery/backend_resolve.go @@ -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 "" +} diff --git a/core/gallery/gallery.go b/core/gallery/gallery.go index 035bfcde8..d79b3e569 100644 --- a/core/gallery/gallery.go +++ b/core/gallery/gallery.go @@ -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...) } diff --git a/core/gallery/metadata_type.go b/core/gallery/metadata_type.go index 066cf83a6..717345a83 100644 --- a/core/gallery/metadata_type.go +++ b/core/gallery/metadata_type.go @@ -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"` } diff --git a/core/http/react-ui/e2e/models-gallery.spec.js b/core/http/react-ui/e2e/models-gallery.spec.js new file mode 100644 index 000000000..ed5be1e56 --- /dev/null +++ b/core/http/react-ui/e2e/models-gallery.spec.js @@ -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() + }) +}) diff --git a/core/http/react-ui/playwright.config.js b/core/http/react-ui/playwright.config.js index e804fc089..f2856f418 100644 --- a/core/http/react-ui/playwright.config.js +++ b/core/http/react-ui/playwright.config.js @@ -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, diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 375575c10..d0f44789b 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -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; diff --git a/core/http/react-ui/src/components/ModelSelector.jsx b/core/http/react-ui/src/components/ModelSelector.jsx index b932e80ba..1974a4927 100644 --- a/core/http/react-ui/src/components/ModelSelector.jsx +++ b/core/http/react-ui/src/components/ModelSelector.jsx @@ -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 ( - + ) } diff --git a/core/http/react-ui/src/components/SearchableSelect.jsx b/core/http/react-ui/src/components/SearchableSelect.jsx new file mode 100644 index 000000000..c2c4cfc07 --- /dev/null +++ b/core/http/react-ui/src/components/SearchableSelect.jsx @@ -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 ( +
+ + {open && ( +
+
+ { setQuery(e.target.value); setFocusIndex(-1) }} + onKeyDown={handleKeyDown} + style={{ width: '100%', padding: '4px 8px', fontSize: '0.8125rem' }} + /> +
+
+ {allOption && ( +
select('')} + style={itemStyle(!value, focusIndex === -1 && enterTarget?.type === 'all')} + onMouseEnter={() => setFocusIndex(-1)} + > + {allOption} + {enterTarget?.type === 'all' && ( + + )} +
+ )} + {filtered.map((o, i) => { + const isActive = value === o.value + const isEnterTarget = enterTarget?.type === 'item' && enterTarget.index === i + const isFocused = focusIndex === i || isEnterTarget + return ( +
select(o.value)} + style={itemStyle(isActive, isFocused)} + onMouseEnter={() => setFocusIndex(i)} + > + {o.label} + {isEnterTarget && ( + + )} +
+ ) + })} + {filtered.length === 0 && !allOption && ( +
+ No matches +
+ )} +
+
+ )} +
+ ) +} diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx index 84cab9041..8224dd4bd 100644 --- a/core/http/react-ui/src/pages/Chat.jsx +++ b/core/http/react-ui/src/pages/Chat.jsx @@ -837,32 +837,6 @@ export default function Chat() { {activeChat.name} - updateChatSettings(activeChat.id, { model })} - capability="FLAG_CHAT" - /> - {activeChat.model && ( - <> - {modelInfo?.backend && ( - {modelInfo.backend} - )} - - - - )} + updateChatSettings(activeChat.id, { model })} + capability="FLAG_CHAT" + style={{ flex: '1 1 0', minWidth: 120 }} + />
+ {activeChat.model && ( + <> + + + + )}
+ {/* Backend */} + + {model.backend ? ( + + {model.backend} + + ) : ( + + )} + + {/* Size / VRAM */}
@@ -476,7 +506,7 @@ export default function Models() { {/* Expanded detail row */} {isExpanded && ( - + @@ -539,6 +569,13 @@ function ModelDetail({ model, fit, expandedFiles, setExpandedFiles }) { )} + + {model.backend && ( + + {model.backend} + + )} + {model.estimated_size_display && model.estimated_size_display !== '0 B' ? model.estimated_size_display : null} diff --git a/core/http/react-ui/src/pages/Talk.jsx b/core/http/react-ui/src/pages/Talk.jsx index 0700e46d3..bea7d6aaf 100644 --- a/core/http/react-ui/src/pages/Talk.jsx +++ b/core/http/react-ui/src/pages/Talk.jsx @@ -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() { - + options={pipelineModelNames} + loading={modelsLoading} + disabled={isConnected} + searchPlaceholder="Search pipeline models..." + />
{/* Pipeline details */} diff --git a/core/http/routes/ui_api.go b/core/http/routes/ui_api.go index 530113293..dc67cf0fa 100644 --- a/core/http/routes/ui_api.go +++ b/core/http/routes/ui_api.go @@ -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, diff --git a/tests/e2e-ui/Dockerfile b/tests/e2e-ui/Dockerfile index cfde9ef65..982ba0365 100644 --- a/tests/e2e-ui/Dockerfile +++ b/tests/e2e-ui/Dockerfile @@ -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 \ "]